mirror of
https://github.com/AutoMaker-Org/automaker.git
synced 2026-02-04 09:13:08 +00:00
Merge pull request #286 from mzubair481/feature/mcp-server-support
feat: add MCP server support
This commit is contained in:
@@ -28,6 +28,7 @@
|
|||||||
"@automaker/prompts": "^1.0.0",
|
"@automaker/prompts": "^1.0.0",
|
||||||
"@automaker/types": "^1.0.0",
|
"@automaker/types": "^1.0.0",
|
||||||
"@automaker/utils": "^1.0.0",
|
"@automaker/utils": "^1.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
|
|||||||
@@ -50,6 +50,8 @@ import { createGitHubRoutes } from './routes/github/index.js';
|
|||||||
import { createContextRoutes } from './routes/context/index.js';
|
import { createContextRoutes } from './routes/context/index.js';
|
||||||
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
import { createBacklogPlanRoutes } from './routes/backlog-plan/index.js';
|
||||||
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
import { cleanupStaleValidations } from './routes/github/routes/validation-common.js';
|
||||||
|
import { createMCPRoutes } from './routes/mcp/index.js';
|
||||||
|
import { MCPTestService } from './services/mcp-test-service.js';
|
||||||
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
import { createPipelineRoutes } from './routes/pipeline/index.js';
|
||||||
import { pipelineService } from './services/pipeline-service.js';
|
import { pipelineService } from './services/pipeline-service.js';
|
||||||
|
|
||||||
@@ -103,9 +105,13 @@ if (ENABLE_REQUEST_LOGGING) {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// SECURITY: Restrict CORS to localhost UI origins to prevent drive-by attacks
|
||||||
|
// from malicious websites. MCP server endpoints can execute arbitrary commands,
|
||||||
|
// so allowing any origin would enable RCE from any website visited while Automaker runs.
|
||||||
|
const DEFAULT_CORS_ORIGINS = ['http://localhost:3007', 'http://127.0.0.1:3007'];
|
||||||
app.use(
|
app.use(
|
||||||
cors({
|
cors({
|
||||||
origin: process.env.CORS_ORIGIN || '*',
|
origin: process.env.CORS_ORIGIN || DEFAULT_CORS_ORIGINS,
|
||||||
credentials: true,
|
credentials: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
@@ -121,6 +127,7 @@ const agentService = new AgentService(DATA_DIR, events, settingsService);
|
|||||||
const featureLoader = new FeatureLoader();
|
const featureLoader = new FeatureLoader();
|
||||||
const autoModeService = new AutoModeService(events, settingsService);
|
const autoModeService = new AutoModeService(events, settingsService);
|
||||||
const claudeUsageService = new ClaudeUsageService();
|
const claudeUsageService = new ClaudeUsageService();
|
||||||
|
const mcpTestService = new MCPTestService(settingsService);
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
(async () => {
|
(async () => {
|
||||||
@@ -164,6 +171,7 @@ app.use('/api/claude', createClaudeRoutes(claudeUsageService));
|
|||||||
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
app.use('/api/github', createGitHubRoutes(events, settingsService));
|
||||||
app.use('/api/context', createContextRoutes(settingsService));
|
app.use('/api/context', createContextRoutes(settingsService));
|
||||||
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
app.use('/api/backlog-plan', createBacklogPlanRoutes(events, settingsService));
|
||||||
|
app.use('/api/mcp', createMCPRoutes(mcpTestService));
|
||||||
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
app.use('/api/pipeline', createPipelineRoutes(pipelineService));
|
||||||
|
|
||||||
// Create HTTP server
|
// Create HTTP server
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
import type { Options } from '@anthropic-ai/claude-agent-sdk';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { resolveModelString } from '@automaker/model-resolver';
|
import { resolveModelString } from '@automaker/model-resolver';
|
||||||
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP } from '@automaker/types';
|
import { DEFAULT_MODELS, CLAUDE_MODEL_MAP, type McpServerConfig } from '@automaker/types';
|
||||||
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
import { isPathAllowed, PathNotAllowedError, getAllowedRootDirectory } from '@automaker/platform';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -136,6 +136,53 @@ function getBaseOptions(): Partial<Options> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP permission options result
|
||||||
|
*/
|
||||||
|
interface McpPermissionOptions {
|
||||||
|
/** Whether tools should be restricted to a preset */
|
||||||
|
shouldRestrictTools: boolean;
|
||||||
|
/** Options to spread when MCP bypass is enabled */
|
||||||
|
bypassOptions: Partial<Options>;
|
||||||
|
/** Options to spread for MCP servers */
|
||||||
|
mcpServerOptions: Partial<Options>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build MCP-related options based on configuration.
|
||||||
|
* Centralizes the logic for determining permission modes and tool restrictions
|
||||||
|
* when MCP servers are configured.
|
||||||
|
*
|
||||||
|
* @param config - The SDK options config
|
||||||
|
* @returns Object with MCP permission settings to spread into final options
|
||||||
|
*/
|
||||||
|
function buildMcpOptions(config: CreateSdkOptionsConfig): McpPermissionOptions {
|
||||||
|
const hasMcpServers = config.mcpServers && Object.keys(config.mcpServers).length > 0;
|
||||||
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
|
// via the security warning dialog that explains the risks.
|
||||||
|
const mcpAutoApprove = config.mcpAutoApproveTools ?? true;
|
||||||
|
const mcpUnrestricted = config.mcpUnrestrictedTools ?? true;
|
||||||
|
|
||||||
|
// Determine if we should bypass permissions based on settings
|
||||||
|
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||||
|
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||||
|
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||||
|
|
||||||
|
return {
|
||||||
|
shouldRestrictTools,
|
||||||
|
// Only include bypass options when MCP is configured and auto-approve is enabled
|
||||||
|
bypassOptions: shouldBypassPermissions
|
||||||
|
? {
|
||||||
|
permissionMode: 'bypassPermissions' as const,
|
||||||
|
// Required flag when using bypassPermissions mode
|
||||||
|
allowDangerouslySkipPermissions: true,
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
// Include MCP servers if configured
|
||||||
|
mcpServerOptions: config.mcpServers ? { mcpServers: config.mcpServers } : {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
* Build system prompt configuration based on autoLoadClaudeMd setting.
|
||||||
* When autoLoadClaudeMd is true:
|
* When autoLoadClaudeMd is true:
|
||||||
@@ -219,8 +266,25 @@ export interface CreateSdkOptionsConfig {
|
|||||||
|
|
||||||
/** Enable sandbox mode for bash command isolation */
|
/** Enable sandbox mode for bash command isolation */
|
||||||
enableSandboxMode?: boolean;
|
enableSandboxMode?: boolean;
|
||||||
|
|
||||||
|
/** MCP servers to make available to the agent */
|
||||||
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
|
||||||
|
/** Auto-approve MCP tool calls without permission prompts */
|
||||||
|
mcpAutoApproveTools?: boolean;
|
||||||
|
|
||||||
|
/** Allow unrestricted tools when MCP servers are enabled */
|
||||||
|
mcpUnrestrictedTools?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-export MCP types from @automaker/types for convenience
|
||||||
|
export type {
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
|
} from '@automaker/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Create SDK options for spec generation
|
* Create SDK options for spec generation
|
||||||
*
|
*
|
||||||
@@ -330,12 +394,18 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build CLAUDE.md auto-loading options if enabled
|
// Build CLAUDE.md auto-loading options if enabled
|
||||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||||
|
|
||||||
|
// Build MCP-related options
|
||||||
|
const mcpOptions = buildMcpOptions(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('chat', effectiveModel),
|
model: getModelForUseCase('chat', effectiveModel),
|
||||||
maxTurns: MAX_TURNS.standard,
|
maxTurns: MAX_TURNS.standard,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.chat],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.chat] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
...(config.enableSandboxMode && {
|
...(config.enableSandboxMode && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -344,6 +414,7 @@ export function createChatOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
}),
|
}),
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,12 +435,18 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
// Build CLAUDE.md auto-loading options if enabled
|
// Build CLAUDE.md auto-loading options if enabled
|
||||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||||
|
|
||||||
|
// Build MCP-related options
|
||||||
|
const mcpOptions = buildMcpOptions(config);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('auto', config.model),
|
model: getModelForUseCase('auto', config.model),
|
||||||
maxTurns: MAX_TURNS.maximum,
|
maxTurns: MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: [...TOOL_PRESETS.fullAccess],
|
// Only restrict tools if no MCP servers configured or unrestricted is disabled
|
||||||
|
...(mcpOptions.shouldRestrictTools && { allowedTools: [...TOOL_PRESETS.fullAccess] }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
...(config.enableSandboxMode && {
|
...(config.enableSandboxMode && {
|
||||||
sandbox: {
|
sandbox: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -378,6 +455,7 @@ export function createAutoModeOptions(config: CreateSdkOptionsConfig): Options {
|
|||||||
}),
|
}),
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,14 +478,27 @@ export function createCustomOptions(
|
|||||||
// Build CLAUDE.md auto-loading options if enabled
|
// Build CLAUDE.md auto-loading options if enabled
|
||||||
const claudeMdOptions = buildClaudeMdOptions(config);
|
const claudeMdOptions = buildClaudeMdOptions(config);
|
||||||
|
|
||||||
|
// Build MCP-related options
|
||||||
|
const mcpOptions = buildMcpOptions(config);
|
||||||
|
|
||||||
|
// For custom options: use explicit allowedTools if provided, otherwise use preset based on MCP settings
|
||||||
|
const effectiveAllowedTools = config.allowedTools
|
||||||
|
? [...config.allowedTools]
|
||||||
|
: mcpOptions.shouldRestrictTools
|
||||||
|
? [...TOOL_PRESETS.readOnly]
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...getBaseOptions(),
|
...getBaseOptions(),
|
||||||
model: getModelForUseCase('default', config.model),
|
model: getModelForUseCase('default', config.model),
|
||||||
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
maxTurns: config.maxTurns ?? MAX_TURNS.maximum,
|
||||||
cwd: config.cwd,
|
cwd: config.cwd,
|
||||||
allowedTools: config.allowedTools ? [...config.allowedTools] : [...TOOL_PRESETS.readOnly],
|
...(effectiveAllowedTools && { allowedTools: effectiveAllowedTools }),
|
||||||
...(config.sandbox && { sandbox: config.sandbox }),
|
...(config.sandbox && { sandbox: config.sandbox }),
|
||||||
|
// Apply MCP bypass options if configured
|
||||||
|
...mcpOptions.bypassOptions,
|
||||||
...claudeMdOptions,
|
...claudeMdOptions,
|
||||||
...(config.abortController && { abortController: config.abortController }),
|
...(config.abortController && { abortController: config.abortController }),
|
||||||
|
...mcpOptions.mcpServerOptions,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import type { SettingsService } from '../services/settings-service.js';
|
import type { SettingsService } from '../services/settings-service.js';
|
||||||
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
import type { ContextFilesResult, ContextFileInfo } from '@automaker/utils';
|
||||||
|
import type { MCPServerConfig, McpServerConfig } from '@automaker/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
* Get the autoLoadClaudeMd setting, with project settings taking precedence over global.
|
||||||
@@ -136,3 +137,121 @@ function formatContextFileEntry(file: ContextFileInfo): string {
|
|||||||
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
|
const descriptionInfo = file.description ? `\n**Purpose:** ${file.description}` : '';
|
||||||
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
return `${header}\n${pathInfo}${descriptionInfo}\n\n${file.content}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enabled MCP servers from global settings, converted to SDK format.
|
||||||
|
* Returns an empty object if settings service is not available or no servers are configured.
|
||||||
|
*
|
||||||
|
* @param settingsService - Optional settings service instance
|
||||||
|
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||||
|
* @returns Promise resolving to MCP servers in SDK format (keyed by name)
|
||||||
|
*/
|
||||||
|
export async function getMCPServersFromSettings(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<Record<string, McpServerConfig>> {
|
||||||
|
if (!settingsService) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const mcpServers = globalSettings.mcpServers || [];
|
||||||
|
|
||||||
|
// Filter to only enabled servers and convert to SDK format
|
||||||
|
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||||
|
|
||||||
|
if (enabledServers.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert settings format to SDK format (keyed by name)
|
||||||
|
const sdkServers: Record<string, McpServerConfig> = {};
|
||||||
|
for (const server of enabledServers) {
|
||||||
|
sdkServers[server.name] = convertToSdkFormat(server);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`${logPrefix} Loaded ${enabledServers.length} MCP server(s): ${enabledServers.map((s) => s.name).join(', ')}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return sdkServers;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${logPrefix} Failed to load MCP servers setting:`, error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get MCP permission settings from global settings.
|
||||||
|
*
|
||||||
|
* @param settingsService - Optional settings service instance
|
||||||
|
* @param logPrefix - Prefix for log messages (e.g., '[AgentService]')
|
||||||
|
* @returns Promise resolving to MCP permission settings
|
||||||
|
*/
|
||||||
|
export async function getMCPPermissionSettings(
|
||||||
|
settingsService?: SettingsService | null,
|
||||||
|
logPrefix = '[SettingsHelper]'
|
||||||
|
): Promise<{ mcpAutoApproveTools: boolean; mcpUnrestrictedTools: boolean }> {
|
||||||
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
|
// via the security warning dialog that explains the risks.
|
||||||
|
const defaults = { mcpAutoApproveTools: true, mcpUnrestrictedTools: true };
|
||||||
|
|
||||||
|
if (!settingsService) {
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const globalSettings = await settingsService.getGlobalSettings();
|
||||||
|
const result = {
|
||||||
|
mcpAutoApproveTools: globalSettings.mcpAutoApproveTools ?? true,
|
||||||
|
mcpUnrestrictedTools: globalSettings.mcpUnrestrictedTools ?? true,
|
||||||
|
};
|
||||||
|
console.log(
|
||||||
|
`${logPrefix} MCP permission settings: autoApprove=${result.mcpAutoApproveTools}, unrestricted=${result.mcpUnrestrictedTools}`
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`${logPrefix} Failed to load MCP permission settings:`, error);
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a settings MCPServerConfig to SDK McpServerConfig format.
|
||||||
|
* Validates required fields and throws informative errors if missing.
|
||||||
|
*/
|
||||||
|
function convertToSdkFormat(server: MCPServerConfig): McpServerConfig {
|
||||||
|
if (server.type === 'sse') {
|
||||||
|
if (!server.url) {
|
||||||
|
throw new Error(`SSE MCP server "${server.name}" is missing a URL.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'sse',
|
||||||
|
url: server.url,
|
||||||
|
headers: server.headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type === 'http') {
|
||||||
|
if (!server.url) {
|
||||||
|
throw new Error(`HTTP MCP server "${server.name}" is missing a URL.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'http',
|
||||||
|
url: server.url,
|
||||||
|
headers: server.headers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to stdio
|
||||||
|
if (!server.command) {
|
||||||
|
throw new Error(`Stdio MCP server "${server.name}" is missing a command.`);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: 'stdio',
|
||||||
|
command: server.command,
|
||||||
|
args: server.args,
|
||||||
|
env: server.env,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -36,16 +36,33 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
} = options;
|
} = options;
|
||||||
|
|
||||||
// Build Claude SDK options
|
// Build Claude SDK options
|
||||||
|
// MCP permission logic - determines how to handle tool permissions when MCP servers are configured.
|
||||||
|
// This logic mirrors buildMcpOptions() in sdk-options.ts but is applied here since
|
||||||
|
// the provider is the final point where SDK options are constructed.
|
||||||
|
const hasMcpServers = options.mcpServers && Object.keys(options.mcpServers).length > 0;
|
||||||
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
|
// via the security warning dialog that explains the risks.
|
||||||
|
const mcpAutoApprove = options.mcpAutoApproveTools ?? true;
|
||||||
|
const mcpUnrestricted = options.mcpUnrestrictedTools ?? true;
|
||||||
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
const defaultTools = ['Read', 'Write', 'Edit', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
|
||||||
const toolsToUse = allowedTools || defaultTools;
|
|
||||||
|
// Determine permission mode based on settings
|
||||||
|
const shouldBypassPermissions = hasMcpServers && mcpAutoApprove;
|
||||||
|
// Determine if we should restrict tools (only when no MCP or unrestricted is disabled)
|
||||||
|
const shouldRestrictTools = !hasMcpServers || !mcpUnrestricted;
|
||||||
|
|
||||||
const sdkOptions: Options = {
|
const sdkOptions: Options = {
|
||||||
model,
|
model,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
maxTurns,
|
maxTurns,
|
||||||
cwd,
|
cwd,
|
||||||
allowedTools: toolsToUse,
|
// Only restrict tools if explicitly set OR (no MCP / unrestricted disabled)
|
||||||
permissionMode: 'default',
|
...(allowedTools && shouldRestrictTools && { allowedTools }),
|
||||||
|
...(!allowedTools && shouldRestrictTools && { allowedTools: defaultTools }),
|
||||||
|
// When MCP servers are configured and auto-approve is enabled, use bypassPermissions
|
||||||
|
permissionMode: shouldBypassPermissions ? 'bypassPermissions' : 'default',
|
||||||
|
// Required when using bypassPermissions mode
|
||||||
|
...(shouldBypassPermissions && { allowDangerouslySkipPermissions: true }),
|
||||||
abortController,
|
abortController,
|
||||||
// Resume existing SDK session if we have a session ID
|
// Resume existing SDK session if we have a session ID
|
||||||
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
...(sdkSessionId && conversationHistory && conversationHistory.length > 0
|
||||||
@@ -55,6 +72,8 @@ export class ClaudeProvider extends BaseProvider {
|
|||||||
...(options.settingSources && { settingSources: options.settingSources }),
|
...(options.settingSources && { settingSources: options.settingSources }),
|
||||||
// Forward sandbox configuration
|
// Forward sandbox configuration
|
||||||
...(options.sandbox && { sandbox: options.sandbox }),
|
...(options.sandbox && { sandbox: options.sandbox }),
|
||||||
|
// Forward MCP servers configuration
|
||||||
|
...(options.mcpServers && { mcpServers: options.mcpServers }),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt payload
|
// Build prompt payload
|
||||||
|
|||||||
@@ -1,41 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* Shared types for AI model providers
|
* Shared types for AI model providers
|
||||||
|
*
|
||||||
|
* Re-exports types from @automaker/types for consistency across the codebase.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/**
|
// Re-export all provider types from @automaker/types
|
||||||
* Configuration for a provider instance
|
export type {
|
||||||
*/
|
ProviderConfig,
|
||||||
export interface ProviderConfig {
|
ConversationMessage,
|
||||||
apiKey?: string;
|
ExecuteOptions,
|
||||||
cliPath?: string;
|
McpServerConfig,
|
||||||
env?: Record<string, string>;
|
McpStdioServerConfig,
|
||||||
}
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
/**
|
} from '@automaker/types';
|
||||||
* Message in conversation history
|
|
||||||
*/
|
|
||||||
export interface ConversationMessage {
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string | Array<{ type: string; text?: string; source?: object }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Options for executing a query via a provider
|
|
||||||
*/
|
|
||||||
export interface ExecuteOptions {
|
|
||||||
prompt: string | Array<{ type: string; text?: string; source?: object }>;
|
|
||||||
model: string;
|
|
||||||
cwd: string;
|
|
||||||
systemPrompt?: string | { type: 'preset'; preset: 'claude_code'; append?: string };
|
|
||||||
maxTurns?: number;
|
|
||||||
allowedTools?: string[];
|
|
||||||
mcpServers?: Record<string, unknown>;
|
|
||||||
abortController?: AbortController;
|
|
||||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
|
||||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
|
||||||
settingSources?: Array<'user' | 'project' | 'local'>; // Claude filesystem settings to load
|
|
||||||
sandbox?: { enabled: boolean; autoAllowBashIfSandboxed?: boolean }; // Sandbox configuration
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Content block in a provider message (matches Claude SDK format)
|
* Content block in a provider message (matches Claude SDK format)
|
||||||
|
|||||||
20
apps/server/src/routes/mcp/common.ts
Normal file
20
apps/server/src/routes/mcp/common.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Common utilities for MCP routes
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from unknown error
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Log error with prefix
|
||||||
|
*/
|
||||||
|
export function logError(error: unknown, message: string): void {
|
||||||
|
console.error(`[MCP] ${message}:`, error);
|
||||||
|
}
|
||||||
36
apps/server/src/routes/mcp/index.ts
Normal file
36
apps/server/src/routes/mcp/index.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* MCP routes - HTTP API for testing MCP servers
|
||||||
|
*
|
||||||
|
* Provides endpoints for:
|
||||||
|
* - Testing MCP server connections
|
||||||
|
* - Listing available tools from MCP servers
|
||||||
|
*
|
||||||
|
* Mounted at /api/mcp in the main server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Router } from 'express';
|
||||||
|
import type { MCPTestService } from '../../services/mcp-test-service.js';
|
||||||
|
import { createTestServerHandler } from './routes/test-server.js';
|
||||||
|
import { createListToolsHandler } from './routes/list-tools.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create MCP router with all endpoints
|
||||||
|
*
|
||||||
|
* Endpoints:
|
||||||
|
* - POST /test - Test MCP server connection
|
||||||
|
* - POST /tools - List tools from MCP server
|
||||||
|
*
|
||||||
|
* @param mcpTestService - Instance of MCPTestService for testing connections
|
||||||
|
* @returns Express Router configured with all MCP endpoints
|
||||||
|
*/
|
||||||
|
export function createMCPRoutes(mcpTestService: MCPTestService): Router {
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
// Test MCP server connection
|
||||||
|
router.post('/test', createTestServerHandler(mcpTestService));
|
||||||
|
|
||||||
|
// List tools from MCP server
|
||||||
|
router.post('/tools', createListToolsHandler(mcpTestService));
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
57
apps/server/src/routes/mcp/routes/list-tools.ts
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/mcp/tools - List tools for an MCP server
|
||||||
|
*
|
||||||
|
* Lists available tools for an MCP server.
|
||||||
|
* Similar to test but focused on tool discovery.
|
||||||
|
*
|
||||||
|
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||||
|
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||||
|
* Users must explicitly save a server config through the UI before testing.
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* { serverId: string } - Get tools by server ID from settings
|
||||||
|
*
|
||||||
|
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface ListToolsRequest {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler factory for POST /api/mcp/tools
|
||||||
|
*/
|
||||||
|
export function createListToolsHandler(mcpTestService: MCPTestService) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body as ListToolsRequest;
|
||||||
|
|
||||||
|
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'serverId is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mcpTestService.testServerById(body.serverId);
|
||||||
|
|
||||||
|
// Return only tool-related information
|
||||||
|
res.json({
|
||||||
|
success: result.success,
|
||||||
|
tools: result.tools,
|
||||||
|
error: result.error,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'List tools failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
50
apps/server/src/routes/mcp/routes/test-server.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* POST /api/mcp/test - Test MCP server connection and list tools
|
||||||
|
*
|
||||||
|
* Tests connection to an MCP server and returns available tools.
|
||||||
|
*
|
||||||
|
* SECURITY: Only accepts serverId to look up saved configs. Does NOT accept
|
||||||
|
* arbitrary serverConfig to prevent drive-by command execution attacks.
|
||||||
|
* Users must explicitly save a server config through the UI before testing.
|
||||||
|
*
|
||||||
|
* Request body:
|
||||||
|
* { serverId: string } - Test server by ID from settings
|
||||||
|
*
|
||||||
|
* Response: { success: boolean, tools?: MCPToolInfo[], error?: string, connectionTime?: number }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Request, Response } from 'express';
|
||||||
|
import type { MCPTestService } from '../../../services/mcp-test-service.js';
|
||||||
|
import { getErrorMessage, logError } from '../common.js';
|
||||||
|
|
||||||
|
interface TestServerRequest {
|
||||||
|
serverId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create handler factory for POST /api/mcp/test
|
||||||
|
*/
|
||||||
|
export function createTestServerHandler(mcpTestService: MCPTestService) {
|
||||||
|
return async (req: Request, res: Response): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const body = req.body as TestServerRequest;
|
||||||
|
|
||||||
|
if (!body.serverId || typeof body.serverId !== 'string') {
|
||||||
|
res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'serverId is required',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await mcpTestService.testServerById(body.serverId);
|
||||||
|
res.json(result);
|
||||||
|
} catch (error) {
|
||||||
|
logError(error, 'Test server failed');
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getEnableSandboxModeSetting,
|
getEnableSandboxModeSetting,
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
|
getMCPServersFromSettings,
|
||||||
|
getMCPPermissionSettings,
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@@ -227,6 +229,12 @@ export class AgentService {
|
|||||||
'[AgentService]'
|
'[AgentService]'
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Load MCP servers from settings (global setting only)
|
||||||
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AgentService]');
|
||||||
|
|
||||||
|
// Load MCP permission settings (global setting only)
|
||||||
|
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AgentService]');
|
||||||
|
|
||||||
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
// Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.)
|
||||||
const contextResult = await loadContextFiles({
|
const contextResult = await loadContextFiles({
|
||||||
projectPath: effectiveWorkDir,
|
projectPath: effectiveWorkDir,
|
||||||
@@ -252,6 +260,9 @@ export class AgentService {
|
|||||||
abortController: session.abortController!,
|
abortController: session.abortController!,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
enableSandboxMode,
|
enableSandboxMode,
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract model, maxTurns, and allowedTools from SDK options
|
// Extract model, maxTurns, and allowedTools from SDK options
|
||||||
@@ -275,6 +286,9 @@ export class AgentService {
|
|||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build prompt content with images
|
// Build prompt content with images
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import {
|
|||||||
getAutoLoadClaudeMdSetting,
|
getAutoLoadClaudeMdSetting,
|
||||||
getEnableSandboxModeSetting,
|
getEnableSandboxModeSetting,
|
||||||
filterClaudeMdFromContext,
|
filterClaudeMdFromContext,
|
||||||
|
getMCPServersFromSettings,
|
||||||
|
getMCPPermissionSettings,
|
||||||
} from '../lib/settings-helpers.js';
|
} from '../lib/settings-helpers.js';
|
||||||
|
|
||||||
const execAsync = promisify(exec);
|
const execAsync = promisify(exec);
|
||||||
@@ -1996,6 +1998,12 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
// Load enableSandboxMode setting (global setting only)
|
// Load enableSandboxMode setting (global setting only)
|
||||||
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
const enableSandboxMode = await getEnableSandboxModeSetting(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
|
// Load MCP servers from settings (global setting only)
|
||||||
|
const mcpServers = await getMCPServersFromSettings(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
|
// Load MCP permission settings (global setting only)
|
||||||
|
const mcpPermissions = await getMCPPermissionSettings(this.settingsService, '[AutoMode]');
|
||||||
|
|
||||||
// Build SDK options using centralized configuration for feature implementation
|
// Build SDK options using centralized configuration for feature implementation
|
||||||
const sdkOptions = createAutoModeOptions({
|
const sdkOptions = createAutoModeOptions({
|
||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
@@ -2003,6 +2011,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
abortController,
|
abortController,
|
||||||
autoLoadClaudeMd,
|
autoLoadClaudeMd,
|
||||||
enableSandboxMode,
|
enableSandboxMode,
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract model, maxTurns, and allowedTools from SDK options
|
// Extract model, maxTurns, and allowedTools from SDK options
|
||||||
@@ -2044,6 +2055,9 @@ This mock response was generated because AUTOMAKER_MOCK_AGENT=true was set.
|
|||||||
systemPrompt: sdkOptions.systemPrompt,
|
systemPrompt: sdkOptions.systemPrompt,
|
||||||
settingSources: sdkOptions.settingSources,
|
settingSources: sdkOptions.settingSources,
|
||||||
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
sandbox: sdkOptions.sandbox, // Pass sandbox configuration
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools, // Pass MCP auto-approve setting
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools, // Pass MCP unrestricted tools setting
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute via provider
|
// Execute via provider
|
||||||
@@ -2271,6 +2285,9 @@ After generating the revised spec, output:
|
|||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
let revisionText = '';
|
let revisionText = '';
|
||||||
@@ -2408,6 +2425,9 @@ After generating the revised spec, output:
|
|||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
let taskOutput = '';
|
let taskOutput = '';
|
||||||
@@ -2497,6 +2517,9 @@ Implement all the changes described in the plan above.`;
|
|||||||
cwd: workDir,
|
cwd: workDir,
|
||||||
allowedTools: allowedTools,
|
allowedTools: allowedTools,
|
||||||
abortController,
|
abortController,
|
||||||
|
mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined,
|
||||||
|
mcpAutoApproveTools: mcpPermissions.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: mcpPermissions.mcpUnrestrictedTools,
|
||||||
});
|
});
|
||||||
|
|
||||||
for await (const msg of continuationStream) {
|
for await (const msg of continuationStream) {
|
||||||
|
|||||||
208
apps/server/src/services/mcp-test-service.ts
Normal file
208
apps/server/src/services/mcp-test-service.ts
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* MCP Test Service
|
||||||
|
*
|
||||||
|
* Provides functionality to test MCP server connections and list available tools.
|
||||||
|
* Supports stdio, SSE, and HTTP transport types.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
||||||
|
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
||||||
|
import { SSEClientTransport } from '@modelcontextprotocol/sdk/client/sse.js';
|
||||||
|
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
||||||
|
import type { MCPServerConfig, MCPToolInfo } from '@automaker/types';
|
||||||
|
import type { SettingsService } from './settings-service.js';
|
||||||
|
|
||||||
|
const DEFAULT_TIMEOUT = 10000; // 10 seconds
|
||||||
|
|
||||||
|
export interface MCPTestResult {
|
||||||
|
success: boolean;
|
||||||
|
tools?: MCPToolInfo[];
|
||||||
|
error?: string;
|
||||||
|
connectionTime?: number;
|
||||||
|
serverInfo?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP Test Service for testing server connections and listing tools
|
||||||
|
*/
|
||||||
|
export class MCPTestService {
|
||||||
|
private settingsService: SettingsService;
|
||||||
|
|
||||||
|
constructor(settingsService: SettingsService) {
|
||||||
|
this.settingsService = settingsService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connection to an MCP server and list its tools
|
||||||
|
*/
|
||||||
|
async testServer(serverConfig: MCPServerConfig): Promise<MCPTestResult> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
let client: Client | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
client = new Client({
|
||||||
|
name: 'automaker-mcp-test',
|
||||||
|
version: '1.0.0',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create transport based on server type
|
||||||
|
const transport = await this.createTransport(serverConfig);
|
||||||
|
|
||||||
|
// Connect with timeout
|
||||||
|
await Promise.race([
|
||||||
|
client.connect(transport),
|
||||||
|
this.timeout(DEFAULT_TIMEOUT, 'Connection timeout'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// List tools with timeout
|
||||||
|
const toolsResult = await Promise.race([
|
||||||
|
client.listTools(),
|
||||||
|
this.timeout<{
|
||||||
|
tools: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
}>;
|
||||||
|
}>(DEFAULT_TIMEOUT, 'List tools timeout'),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const connectionTime = Date.now() - startTime;
|
||||||
|
|
||||||
|
// Convert tools to MCPToolInfo format
|
||||||
|
const tools: MCPToolInfo[] = (toolsResult.tools || []).map(
|
||||||
|
(tool: { name: string; description?: string; inputSchema?: Record<string, unknown> }) => ({
|
||||||
|
name: tool.name,
|
||||||
|
description: tool.description,
|
||||||
|
inputSchema: tool.inputSchema,
|
||||||
|
enabled: true,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
tools,
|
||||||
|
connectionTime,
|
||||||
|
serverInfo: {
|
||||||
|
name: serverConfig.name,
|
||||||
|
version: undefined, // Could be extracted from server info if available
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
const connectionTime = Date.now() - startTime;
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.getErrorMessage(error),
|
||||||
|
connectionTime,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
// Clean up client connection
|
||||||
|
if (client) {
|
||||||
|
try {
|
||||||
|
await client.close();
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test server by ID (looks up config from settings)
|
||||||
|
*/
|
||||||
|
async testServerById(serverId: string): Promise<MCPTestResult> {
|
||||||
|
try {
|
||||||
|
const globalSettings = await this.settingsService.getGlobalSettings();
|
||||||
|
const serverConfig = globalSettings.mcpServers?.find((s) => s.id === serverId);
|
||||||
|
|
||||||
|
if (!serverConfig) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `Server with ID "${serverId}" not found`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.testServer(serverConfig);
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: this.getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create appropriate transport based on server type
|
||||||
|
*/
|
||||||
|
private async createTransport(
|
||||||
|
config: MCPServerConfig
|
||||||
|
): Promise<StdioClientTransport | SSEClientTransport | StreamableHTTPClientTransport> {
|
||||||
|
if (config.type === 'sse') {
|
||||||
|
if (!config.url) {
|
||||||
|
throw new Error('URL is required for SSE transport');
|
||||||
|
}
|
||||||
|
// Use eventSourceInit workaround for SSE headers (SDK bug workaround)
|
||||||
|
// See: https://github.com/modelcontextprotocol/typescript-sdk/issues/436
|
||||||
|
const headers = config.headers;
|
||||||
|
return new SSEClientTransport(new URL(config.url), {
|
||||||
|
requestInit: headers ? { headers } : undefined,
|
||||||
|
eventSourceInit: headers
|
||||||
|
? {
|
||||||
|
fetch: (url: string | URL | Request, init?: RequestInit) => {
|
||||||
|
const fetchHeaders = new Headers(init?.headers || {});
|
||||||
|
for (const [key, value] of Object.entries(headers)) {
|
||||||
|
fetchHeaders.set(key, value);
|
||||||
|
}
|
||||||
|
return fetch(url, { ...init, headers: fetchHeaders });
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'http') {
|
||||||
|
if (!config.url) {
|
||||||
|
throw new Error('URL is required for HTTP transport');
|
||||||
|
}
|
||||||
|
return new StreamableHTTPClientTransport(new URL(config.url), {
|
||||||
|
requestInit: config.headers
|
||||||
|
? {
|
||||||
|
headers: config.headers,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default to stdio
|
||||||
|
if (!config.command) {
|
||||||
|
throw new Error('Command is required for stdio transport');
|
||||||
|
}
|
||||||
|
|
||||||
|
return new StdioClientTransport({
|
||||||
|
command: config.command,
|
||||||
|
args: config.args,
|
||||||
|
env: config.env,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a timeout promise
|
||||||
|
*/
|
||||||
|
private timeout<T>(ms: number, message: string): Promise<T> {
|
||||||
|
return new Promise((_, reject) => {
|
||||||
|
setTimeout(() => reject(new Error(message)), ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract error message from unknown error
|
||||||
|
*/
|
||||||
|
private getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
365
apps/server/tests/unit/lib/settings-helpers.test.ts
Normal file
@@ -0,0 +1,365 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { getMCPServersFromSettings, getMCPPermissionSettings } from '@/lib/settings-helpers.js';
|
||||||
|
import type { SettingsService } from '@/services/settings-service.js';
|
||||||
|
|
||||||
|
describe('settings-helpers.ts', () => {
|
||||||
|
describe('getMCPServersFromSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when settingsService is null', async () => {
|
||||||
|
const result = await getMCPServersFromSettings(null);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when settingsService is undefined', async () => {
|
||||||
|
const result = await getMCPServersFromSettings(undefined);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when no MCP servers configured', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({ mcpServers: [] }),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object when mcpServers is undefined', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert enabled stdio server to SDK format', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'test-server',
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
env: { NODE_ENV: 'test' },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
'test-server': {
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: ['server.js'],
|
||||||
|
env: { NODE_ENV: 'test' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert enabled SSE server to SDK format', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'sse-server',
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://localhost:3000/sse',
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
'sse-server': {
|
||||||
|
type: 'sse',
|
||||||
|
url: 'http://localhost:3000/sse',
|
||||||
|
headers: { Authorization: 'Bearer token' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should convert enabled HTTP server to SDK format', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'http-server',
|
||||||
|
type: 'http',
|
||||||
|
url: 'http://localhost:3000/api',
|
||||||
|
headers: { 'X-API-Key': 'secret' },
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
'http-server': {
|
||||||
|
type: 'http',
|
||||||
|
url: 'http://localhost:3000/api',
|
||||||
|
headers: { 'X-API-Key': 'secret' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter out disabled servers', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'enabled-server',
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
enabled: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '2',
|
||||||
|
name: 'disabled-server',
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'python',
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(Object.keys(result)).toHaveLength(1);
|
||||||
|
expect(result['enabled-server']).toBeDefined();
|
||||||
|
expect(result['disabled-server']).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should treat servers without enabled field as enabled', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'implicit-enabled',
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
// enabled field not set
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result['implicit-enabled']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle multiple enabled servers', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{ id: '1', name: 'server1', type: 'stdio', command: 'node', enabled: true },
|
||||||
|
{ id: '2', name: 'server2', type: 'stdio', command: 'python', enabled: true },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(Object.keys(result)).toHaveLength(2);
|
||||||
|
expect(result['server1']).toBeDefined();
|
||||||
|
expect(result['server2']).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return empty object and log error on exception', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService, '[Test]');
|
||||||
|
expect(result).toEqual({});
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for SSE server without URL', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'bad-sse',
|
||||||
|
type: 'sse',
|
||||||
|
enabled: true,
|
||||||
|
// url missing
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
// The error is caught and logged, returns empty
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for HTTP server without URL', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'bad-http',
|
||||||
|
type: 'http',
|
||||||
|
enabled: true,
|
||||||
|
// url missing
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw error for stdio server without command', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'bad-stdio',
|
||||||
|
type: 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
// command missing
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to stdio type when type is not specified', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpServers: [
|
||||||
|
{
|
||||||
|
id: '1',
|
||||||
|
name: 'no-type',
|
||||||
|
command: 'node',
|
||||||
|
enabled: true,
|
||||||
|
// type not specified, should default to stdio
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPServersFromSettings(mockSettingsService);
|
||||||
|
expect(result['no-type']).toEqual({
|
||||||
|
type: 'stdio',
|
||||||
|
command: 'node',
|
||||||
|
args: undefined,
|
||||||
|
env: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMCPPermissionSettings', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults when settingsService is null', async () => {
|
||||||
|
const result = await getMCPPermissionSettings(null);
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults when settingsService is undefined', async () => {
|
||||||
|
const result = await getMCPPermissionSettings(undefined);
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return settings from service', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpAutoApproveTools: false,
|
||||||
|
mcpUnrestrictedTools: false,
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: false,
|
||||||
|
mcpUnrestrictedTools: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should default to true when settings are undefined', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle mixed settings', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: false,
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPPermissionSettings(mockSettingsService);
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return defaults and log error on exception', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockRejectedValue(new Error('Settings error')),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
const result = await getMCPPermissionSettings(mockSettingsService, '[Test]');
|
||||||
|
expect(result).toEqual({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
|
});
|
||||||
|
expect(console.error).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom log prefix', async () => {
|
||||||
|
const mockSettingsService = {
|
||||||
|
getGlobalSettings: vi.fn().mockResolvedValue({
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
|
}),
|
||||||
|
} as unknown as SettingsService;
|
||||||
|
|
||||||
|
await getMCPPermissionSettings(mockSettingsService, '[CustomPrefix]');
|
||||||
|
expect(console.log).toHaveBeenCalledWith(expect.stringContaining('[CustomPrefix]'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -178,6 +178,13 @@ export function ContextView() {
|
|||||||
// Ensure context directory exists
|
// Ensure context directory exists
|
||||||
await api.mkdir(contextPath);
|
await api.mkdir(contextPath);
|
||||||
|
|
||||||
|
// Ensure metadata file exists (create empty one if not)
|
||||||
|
const metadataPath = `${contextPath}/context-metadata.json`;
|
||||||
|
const metadataExists = await api.exists(metadataPath);
|
||||||
|
if (!metadataExists) {
|
||||||
|
await api.writeFile(metadataPath, JSON.stringify({ files: {} }, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
// Load metadata for descriptions
|
// Load metadata for descriptions
|
||||||
const metadata = await loadMetadata();
|
const metadata = await loadMetadata();
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { AudioSection } from './settings-view/audio/audio-section';
|
|||||||
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
import { KeyboardShortcutsSection } from './settings-view/keyboard-shortcuts/keyboard-shortcuts-section';
|
||||||
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
import { FeatureDefaultsSection } from './settings-view/feature-defaults/feature-defaults-section';
|
||||||
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
import { DangerZoneSection } from './settings-view/danger-zone/danger-zone-section';
|
||||||
|
import { MCPServersSection } from './settings-view/mcp-servers';
|
||||||
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
import type { Project as SettingsProject, Theme } from './settings-view/shared/types';
|
||||||
import type { Project as ElectronProject } from '@/lib/electron';
|
import type { Project as ElectronProject } from '@/lib/electron';
|
||||||
|
|
||||||
@@ -116,6 +117,8 @@ export function SettingsView() {
|
|||||||
{showUsageTracking && <ClaudeUsageSection />}
|
{showUsageTracking && <ClaudeUsageSection />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
case 'mcp-servers':
|
||||||
|
return <MCPServersSection />;
|
||||||
case 'ai-enhancement':
|
case 'ai-enhancement':
|
||||||
return <AIEnhancementSection />;
|
return <AIEnhancementSection />;
|
||||||
case 'appearance':
|
case 'appearance':
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
FlaskConical,
|
FlaskConical,
|
||||||
Trash2,
|
Trash2,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
|
Plug,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import type { SettingsViewId } from '../hooks/use-settings-view';
|
import type { SettingsViewId } from '../hooks/use-settings-view';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ export interface NavigationItem {
|
|||||||
export const NAV_ITEMS: NavigationItem[] = [
|
export const NAV_ITEMS: NavigationItem[] = [
|
||||||
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
{ id: 'api-keys', label: 'API Keys', icon: Key },
|
||||||
{ id: 'claude', label: 'Claude', icon: Terminal },
|
{ id: 'claude', label: 'Claude', icon: Terminal },
|
||||||
|
{ id: 'mcp-servers', label: 'MCP Servers', icon: Plug },
|
||||||
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
|
{ id: 'ai-enhancement', label: 'AI Enhancement', icon: Sparkles },
|
||||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||||
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
{ id: 'terminal', label: 'Terminal', icon: SquareTerminal },
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useState, useCallback } from 'react';
|
|||||||
export type SettingsViewId =
|
export type SettingsViewId =
|
||||||
| 'api-keys'
|
| 'api-keys'
|
||||||
| 'claude'
|
| 'claude'
|
||||||
|
| 'mcp-servers'
|
||||||
| 'ai-enhancement'
|
| 'ai-enhancement'
|
||||||
| 'appearance'
|
| 'appearance'
|
||||||
| 'terminal'
|
| 'terminal'
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { MCPServerHeader } from './mcp-server-header';
|
||||||
|
export { MCPPermissionSettings } from './mcp-permission-settings';
|
||||||
|
export { MCPToolsWarning } from './mcp-tools-warning';
|
||||||
|
export { MCPServerCard } from './mcp-server-card';
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
import { ShieldAlert } from 'lucide-react';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { syncSettingsToServer } from '@/hooks/use-settings-migration';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MCPPermissionSettingsProps {
|
||||||
|
mcpAutoApproveTools: boolean;
|
||||||
|
mcpUnrestrictedTools: boolean;
|
||||||
|
onAutoApproveChange: (checked: boolean) => void;
|
||||||
|
onUnrestrictedChange: (checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPPermissionSettings({
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
onAutoApproveChange,
|
||||||
|
onUnrestrictedChange,
|
||||||
|
}: MCPPermissionSettingsProps) {
|
||||||
|
const hasAnyEnabled = mcpAutoApproveTools || mcpUnrestrictedTools;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-6 py-4 border-b border-border/50 bg-muted/20">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Switch
|
||||||
|
id="mcp-auto-approve"
|
||||||
|
checked={mcpAutoApproveTools}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
onAutoApproveChange(checked);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
}}
|
||||||
|
data-testid="mcp-auto-approve-toggle"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label htmlFor="mcp-auto-approve" className="text-sm font-medium cursor-pointer">
|
||||||
|
Auto-approve MCP tool calls
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, the AI agent can use MCP tools without permission prompts.
|
||||||
|
</p>
|
||||||
|
{mcpAutoApproveTools && (
|
||||||
|
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
Bypasses normal permission checks
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Switch
|
||||||
|
id="mcp-unrestricted"
|
||||||
|
checked={mcpUnrestrictedTools}
|
||||||
|
onCheckedChange={async (checked) => {
|
||||||
|
onUnrestrictedChange(checked);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
}}
|
||||||
|
data-testid="mcp-unrestricted-toggle"
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<div className="space-y-1 flex-1">
|
||||||
|
<Label htmlFor="mcp-unrestricted" className="text-sm font-medium cursor-pointer">
|
||||||
|
Unrestricted tool access
|
||||||
|
</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
When enabled, the AI agent can use any tool, not just the default set.
|
||||||
|
</p>
|
||||||
|
{mcpUnrestrictedTools && (
|
||||||
|
<p className="text-xs text-amber-600 flex items-center gap-1 mt-1">
|
||||||
|
<ShieldAlert className="h-3 w-3" />
|
||||||
|
Agent has full tool access including file writes and bash
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasAnyEnabled && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-md border border-amber-500/30 bg-amber-500/10 p-3 mt-2',
|
||||||
|
'text-xs text-amber-700 dark:text-amber-400'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<p className="font-medium mb-1">Security Note</p>
|
||||||
|
<p>
|
||||||
|
These settings reduce security restrictions for MCP tool usage. Only enable if you
|
||||||
|
trust all configured MCP servers.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
import { ChevronDown, ChevronRight, Code, Pencil, Trash2, PlayCircle, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import type { ServerTestState } from '../types';
|
||||||
|
import { getServerIcon, getTestStatusIcon, maskSensitiveUrl } from '../utils';
|
||||||
|
import { MCPToolsList } from '../mcp-tools-list';
|
||||||
|
|
||||||
|
interface MCPServerCardProps {
|
||||||
|
server: MCPServerConfig;
|
||||||
|
testState?: ServerTestState;
|
||||||
|
isExpanded: boolean;
|
||||||
|
onToggleExpanded: () => void;
|
||||||
|
onTest: () => void;
|
||||||
|
onToggleEnabled: () => void;
|
||||||
|
onEditJson: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPServerCard({
|
||||||
|
server,
|
||||||
|
testState,
|
||||||
|
isExpanded,
|
||||||
|
onToggleExpanded,
|
||||||
|
onTest,
|
||||||
|
onToggleEnabled,
|
||||||
|
onEditJson,
|
||||||
|
onEdit,
|
||||||
|
onDelete,
|
||||||
|
}: MCPServerCardProps) {
|
||||||
|
const Icon = getServerIcon(server.type);
|
||||||
|
const hasTools = testState?.tools && testState.tools.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible open={isExpanded} onOpenChange={onToggleExpanded}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-xl border',
|
||||||
|
server.enabled !== false
|
||||||
|
? 'border-border/50 bg-accent/20'
|
||||||
|
: 'border-border/30 bg-muted/30 opacity-60'
|
||||||
|
)}
|
||||||
|
data-testid={`mcp-server-${server.id}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between p-4 gap-2">
|
||||||
|
<div className="flex items-center gap-3 min-w-0 flex-1 overflow-hidden">
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<button
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-3 text-left min-w-0 flex-1',
|
||||||
|
hasTools && 'cursor-pointer hover:opacity-80'
|
||||||
|
)}
|
||||||
|
disabled={!hasTools}
|
||||||
|
>
|
||||||
|
{hasTools ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronDown className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-4 h-4 text-muted-foreground shrink-0" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'w-8 h-8 rounded-lg flex items-center justify-center shrink-0',
|
||||||
|
server.enabled !== false ? 'bg-brand-500/20' : 'bg-muted'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="w-4 h-4 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<span className="font-medium text-sm truncate">{server.name}</span>
|
||||||
|
{testState && getTestStatusIcon(testState.status)}
|
||||||
|
{testState?.status === 'success' && testState.tools && (
|
||||||
|
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded whitespace-nowrap">
|
||||||
|
{testState.tools.length} tool{testState.tools.length !== 1 ? 's' : ''}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{server.description && (
|
||||||
|
<div className="text-xs text-muted-foreground truncate">
|
||||||
|
{server.description}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="text-xs text-muted-foreground/60 mt-0.5 truncate">
|
||||||
|
{server.type === 'stdio'
|
||||||
|
? `${server.command}${server.args?.length ? ' ' + server.args.join(' ') : ''}`
|
||||||
|
: maskSensitiveUrl(server.url || '')}
|
||||||
|
</div>
|
||||||
|
{testState?.status === 'error' && testState.error && (
|
||||||
|
<div className="text-xs text-destructive mt-1 line-clamp-2 break-words">
|
||||||
|
{testState.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 shrink-0 ml-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={onTest}
|
||||||
|
disabled={testState?.status === 'testing' || server.enabled === false}
|
||||||
|
data-testid={`mcp-server-test-${server.id}`}
|
||||||
|
className="h-8 px-2"
|
||||||
|
>
|
||||||
|
{testState?.status === 'testing' ? (
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<PlayCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
<span className="ml-1.5 text-xs">Test</span>
|
||||||
|
</Button>
|
||||||
|
<Switch
|
||||||
|
checked={server.enabled !== false}
|
||||||
|
onCheckedChange={onToggleEnabled}
|
||||||
|
data-testid={`mcp-server-toggle-${server.id}`}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onEditJson}
|
||||||
|
title="Edit JSON"
|
||||||
|
data-testid={`mcp-server-json-${server.id}`}
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={onEdit}
|
||||||
|
data-testid={`mcp-server-edit-${server.id}`}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
onClick={onDelete}
|
||||||
|
data-testid={`mcp-server-delete-${server.id}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{hasTools && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-4 pb-4 pt-0 ml-7 overflow-hidden">
|
||||||
|
<div className="text-xs font-medium text-muted-foreground mb-2">Available Tools</div>
|
||||||
|
<MCPToolsList
|
||||||
|
tools={testState.tools!}
|
||||||
|
isLoading={testState.status === 'testing'}
|
||||||
|
error={testState.error}
|
||||||
|
className="max-w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import { Plug, RefreshCw, Download, Code, FileJson, Plus } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface MCPServerHeaderProps {
|
||||||
|
isRefreshing: boolean;
|
||||||
|
hasServers: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onExport: () => void;
|
||||||
|
onEditAllJson: () => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onAdd: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPServerHeader({
|
||||||
|
isRefreshing,
|
||||||
|
hasServers,
|
||||||
|
onRefresh,
|
||||||
|
onExport,
|
||||||
|
onEditAllJson,
|
||||||
|
onImport,
|
||||||
|
onAdd,
|
||||||
|
}: MCPServerHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 border-b border-border/50 bg-linear-to-r from-transparent via-accent/5 to-transparent">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<div className="w-9 h-9 rounded-xl bg-linear-to-br from-brand-500/20 to-brand-600/10 flex items-center justify-center border border-brand-500/20">
|
||||||
|
<Plug className="w-5 h-5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h2 className="text-lg font-semibold text-foreground tracking-tight">MCP Servers</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground/80 ml-12">
|
||||||
|
Configure Model Context Protocol servers to extend agent capabilities.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onRefresh}
|
||||||
|
disabled={isRefreshing}
|
||||||
|
data-testid="refresh-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('w-4 h-4', isRefreshing && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
{hasServers && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onExport}
|
||||||
|
data-testid="export-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4 mr-2" />
|
||||||
|
Export
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onEditAllJson}
|
||||||
|
data-testid="edit-all-json-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Edit JSON
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={onImport}
|
||||||
|
data-testid="import-mcp-servers-button"
|
||||||
|
>
|
||||||
|
<FileJson className="w-4 h-4 mr-2" />
|
||||||
|
Import JSON
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={onAdd} data-testid="add-mcp-server-button">
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Add Server
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||||
|
|
||||||
|
interface MCPToolsWarningProps {
|
||||||
|
totalTools: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPToolsWarning({ totalTools }: MCPToolsWarningProps) {
|
||||||
|
return (
|
||||||
|
<div className="mx-6 mt-4 p-3 rounded-lg border border-yellow-500/50 bg-yellow-500/10">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertTriangle className="w-5 h-5 text-yellow-500 shrink-0 mt-0.5" />
|
||||||
|
<div className="text-sm">
|
||||||
|
<p className="font-medium text-yellow-600 dark:text-yellow-400">
|
||||||
|
High tool count detected ({totalTools} tools)
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground mt-1">
|
||||||
|
Having more than {MAX_RECOMMENDED_TOOLS} MCP tools may degrade AI model performance.
|
||||||
|
Consider disabling unused servers or removing unnecessary tools.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Patterns that indicate sensitive values in URLs or config
|
||||||
|
export const SENSITIVE_PARAM_PATTERNS = [
|
||||||
|
/api[-_]?key/i,
|
||||||
|
/api[-_]?token/i,
|
||||||
|
/auth/i,
|
||||||
|
/token/i,
|
||||||
|
/secret/i,
|
||||||
|
/password/i,
|
||||||
|
/credential/i,
|
||||||
|
/bearer/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Maximum recommended MCP tools before performance degradation
|
||||||
|
export const MAX_RECOMMENDED_TOOLS = 80;
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import type { ServerFormData, ServerType } from '../types';
|
||||||
|
|
||||||
|
interface AddEditServerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
editingServer: MCPServerConfig | null;
|
||||||
|
formData: ServerFormData;
|
||||||
|
onFormDataChange: (data: ServerFormData) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddEditServerDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
editingServer,
|
||||||
|
formData,
|
||||||
|
onFormDataChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: AddEditServerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="mcp-server-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{editingServer ? 'Edit MCP Server' : 'Add MCP Server'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Configure an MCP server to extend agent capabilities with custom tools.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-name">Name</Label>
|
||||||
|
<Input
|
||||||
|
id="server-name"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, name: e.target.value })}
|
||||||
|
placeholder="my-mcp-server"
|
||||||
|
data-testid="mcp-server-name-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-description">Description (optional)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, description: e.target.value })}
|
||||||
|
placeholder="What this server provides..."
|
||||||
|
data-testid="mcp-server-description-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-type">Transport Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.type}
|
||||||
|
onValueChange={(value: ServerType) => onFormDataChange({ ...formData, type: value })}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="server-type" data-testid="mcp-server-type-select">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="stdio">Stdio (subprocess)</SelectItem>
|
||||||
|
<SelectItem value="sse">SSE (Server-Sent Events)</SelectItem>
|
||||||
|
<SelectItem value="http">HTTP</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
{formData.type === 'stdio' ? (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-command">Command</Label>
|
||||||
|
<Input
|
||||||
|
id="server-command"
|
||||||
|
value={formData.command}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, command: e.target.value })}
|
||||||
|
placeholder="npx, node, python, etc."
|
||||||
|
data-testid="mcp-server-command-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-args">Arguments (space-separated)</Label>
|
||||||
|
<Input
|
||||||
|
id="server-args"
|
||||||
|
value={formData.args}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, args: e.target.value })}
|
||||||
|
placeholder="-y @modelcontextprotocol/server-filesystem"
|
||||||
|
data-testid="mcp-server-args-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-env">Environment Variables (JSON, optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="server-env"
|
||||||
|
value={formData.env}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, env: e.target.value })}
|
||||||
|
placeholder={'{\n "API_KEY": "your-key"\n}'}
|
||||||
|
className="font-mono text-sm h-24"
|
||||||
|
data-testid="mcp-server-env-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-url">URL</Label>
|
||||||
|
<Input
|
||||||
|
id="server-url"
|
||||||
|
value={formData.url}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, url: e.target.value })}
|
||||||
|
placeholder="https://example.com/mcp"
|
||||||
|
data-testid="mcp-server-url-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="server-headers">Headers (JSON, optional)</Label>
|
||||||
|
<Textarea
|
||||||
|
id="server-headers"
|
||||||
|
value={formData.headers}
|
||||||
|
onChange={(e) => onFormDataChange({ ...formData, headers: e.target.value })}
|
||||||
|
placeholder={
|
||||||
|
'{\n "x-api-key": "your-api-key",\n "Authorization": "Bearer token"\n}'
|
||||||
|
}
|
||||||
|
className="font-mono text-sm h-24"
|
||||||
|
data-testid="mcp-server-headers-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onSave} data-testid="mcp-server-save-button">
|
||||||
|
{editingServer ? 'Save Changes' : 'Add Server'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface DeleteServerDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeleteServerDialog({ open, onOpenChange, onConfirm }: DeleteServerDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent data-testid="mcp-server-delete-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete MCP Server</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Are you sure you want to delete this MCP server? This action cannot be undone.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={onConfirm}
|
||||||
|
data-testid="mcp-server-confirm-delete-button"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import { Code } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface GlobalJsonEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
jsonValue: string;
|
||||||
|
onJsonValueChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlobalJsonEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
jsonValue,
|
||||||
|
onJsonValueChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: GlobalJsonEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-3xl max-h-[90vh]" data-testid="mcp-global-json-edit-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit All MCP Servers</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Edit the full MCP servers configuration. Add, modify, or remove servers directly in
|
||||||
|
JSON. Servers removed from JSON will be deleted.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"server-name": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-name"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
className="font-mono text-sm h-[50vh] min-h-[300px]"
|
||||||
|
data-testid="mcp-global-json-edit-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!jsonValue.trim()}
|
||||||
|
data-testid="mcp-global-json-edit-save-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Save All
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import { FileJson } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface ImportJsonDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
importJson: string;
|
||||||
|
onImportJsonChange: (value: string) => void;
|
||||||
|
onImport: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ImportJsonDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
importJson,
|
||||||
|
onImportJsonChange,
|
||||||
|
onImport,
|
||||||
|
onCancel,
|
||||||
|
}: ImportJsonDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-2xl" data-testid="mcp-import-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Import MCP Servers</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Paste JSON configuration in Claude Code format. Servers with duplicate names will be
|
||||||
|
skipped.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={importJson}
|
||||||
|
onChange={(e) => onImportJsonChange(e.target.value)}
|
||||||
|
placeholder={`{
|
||||||
|
"mcpServers": {
|
||||||
|
"server-name": {
|
||||||
|
"command": "npx",
|
||||||
|
"args": ["-y", "@modelcontextprotocol/server-name"],
|
||||||
|
"type": "stdio"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`}
|
||||||
|
className="font-mono text-sm h-64"
|
||||||
|
data-testid="mcp-import-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onImport} disabled={!importJson.trim()} data-testid="mcp-import-button">
|
||||||
|
<FileJson className="w-4 h-4 mr-2" />
|
||||||
|
Import
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export { AddEditServerDialog } from './add-edit-server-dialog';
|
||||||
|
export { DeleteServerDialog } from './delete-server-dialog';
|
||||||
|
export { ImportJsonDialog } from './import-json-dialog';
|
||||||
|
export { JsonEditDialog } from './json-edit-dialog';
|
||||||
|
export { GlobalJsonEditDialog } from './global-json-edit-dialog';
|
||||||
|
export { SecurityWarningDialog } from './security-warning-dialog';
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { Code } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
|
||||||
|
interface JsonEditDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
server: MCPServerConfig | null;
|
||||||
|
jsonValue: string;
|
||||||
|
onJsonValueChange: (value: string) => void;
|
||||||
|
onSave: () => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function JsonEditDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
server,
|
||||||
|
jsonValue,
|
||||||
|
onJsonValueChange,
|
||||||
|
onSave,
|
||||||
|
onCancel,
|
||||||
|
}: JsonEditDialogProps) {
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
onCancel();
|
||||||
|
} else {
|
||||||
|
onOpenChange(open);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-2xl" data-testid="mcp-json-edit-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Edit Server Configuration</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Edit the raw JSON configuration for "{server?.name}". Changes will be validated before
|
||||||
|
saving.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<Textarea
|
||||||
|
value={jsonValue}
|
||||||
|
onChange={(e) => onJsonValueChange(e.target.value)}
|
||||||
|
placeholder="Server configuration JSON..."
|
||||||
|
className="font-mono text-sm h-80"
|
||||||
|
data-testid="mcp-json-edit-textarea"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={onCancel}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={!jsonValue.trim()}
|
||||||
|
data-testid="mcp-json-edit-save-button"
|
||||||
|
>
|
||||||
|
<Code className="w-4 h-4 mr-2" />
|
||||||
|
Save JSON
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { ShieldAlert, Terminal, Globe } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
|
||||||
|
interface SecurityWarningDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
onConfirm: () => void;
|
||||||
|
serverType: 'stdio' | 'sse' | 'http';
|
||||||
|
serverName: string;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
url?: string;
|
||||||
|
/** Number of servers being imported (for import dialog) */
|
||||||
|
importCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SecurityWarningDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onConfirm,
|
||||||
|
serverType,
|
||||||
|
serverName,
|
||||||
|
command,
|
||||||
|
args,
|
||||||
|
url,
|
||||||
|
importCount,
|
||||||
|
}: SecurityWarningDialogProps) {
|
||||||
|
const isImport = importCount !== undefined && importCount > 0;
|
||||||
|
const isStdio = serverType === 'stdio';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="mcp-security-warning-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldAlert className="h-5 w-5 text-amber-500" />
|
||||||
|
Security Warning
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3 pt-2">
|
||||||
|
<p className="font-medium text-foreground">
|
||||||
|
{isImport
|
||||||
|
? `You are about to import ${importCount} MCP server${importCount > 1 ? 's' : ''}.`
|
||||||
|
: 'MCP servers can execute code on your machine.'}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{!isImport && isStdio && command && (
|
||||||
|
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<Terminal className="h-4 w-4 text-destructive" />
|
||||||
|
This server will run:
|
||||||
|
</div>
|
||||||
|
<code className="mt-1 block break-all text-sm text-muted-foreground">
|
||||||
|
{command} {args?.join(' ')}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isImport && !isStdio && url && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
|
||||||
|
<Globe className="h-4 w-4 text-amber-500" />
|
||||||
|
This server will connect to:
|
||||||
|
</div>
|
||||||
|
<code className="mt-1 block break-all text-sm text-muted-foreground">{url}</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isImport && (
|
||||||
|
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
Each imported server can execute arbitrary commands or connect to external
|
||||||
|
services. Review the JSON carefully before importing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="list-inside list-disc space-y-1 text-sm text-muted-foreground">
|
||||||
|
<li>Only add servers from sources you trust</li>
|
||||||
|
{isStdio && <li>Stdio servers run with your user privileges</li>}
|
||||||
|
{!isStdio && <li>HTTP/SSE servers can access network resources</li>}
|
||||||
|
<li>Review the {isStdio ? 'command' : 'URL'} before confirming</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={onConfirm} data-testid="mcp-security-confirm-button">
|
||||||
|
I understand, {isImport ? 'import' : 'add'} server
|
||||||
|
{isImport && importCount! > 1 ? 's' : ''}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { useMCPServers } from './use-mcp-servers';
|
||||||
@@ -0,0 +1,759 @@
|
|||||||
|
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import type { MCPServerConfig } from '@automaker/types';
|
||||||
|
import { syncSettingsToServer, loadMCPServersFromServer } from '@/hooks/use-settings-migration';
|
||||||
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
|
import type { ServerFormData, ServerTestState } from '../types';
|
||||||
|
import { defaultFormData } from '../types';
|
||||||
|
import { MAX_RECOMMENDED_TOOLS } from '../constants';
|
||||||
|
import type { ServerType } from '../types';
|
||||||
|
|
||||||
|
/** Pending server data waiting for security confirmation */
|
||||||
|
interface PendingServerData {
|
||||||
|
type: 'add' | 'import';
|
||||||
|
serverData?: Omit<MCPServerConfig, 'id'>;
|
||||||
|
importServers?: Array<Omit<MCPServerConfig, 'id'>>;
|
||||||
|
serverType: ServerType;
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMCPServers() {
|
||||||
|
const {
|
||||||
|
mcpServers,
|
||||||
|
addMCPServer,
|
||||||
|
updateMCPServer,
|
||||||
|
removeMCPServer,
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
setMcpAutoApproveTools,
|
||||||
|
setMcpUnrestrictedTools,
|
||||||
|
} = useAppStore();
|
||||||
|
|
||||||
|
// State
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [editingServer, setEditingServer] = useState<MCPServerConfig | null>(null);
|
||||||
|
const [formData, setFormData] = useState<ServerFormData>(defaultFormData);
|
||||||
|
const [deleteConfirmId, setDeleteConfirmId] = useState<string | null>(null);
|
||||||
|
const [isImportDialogOpen, setIsImportDialogOpen] = useState(false);
|
||||||
|
const [importJson, setImportJson] = useState('');
|
||||||
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [serverTestStates, setServerTestStates] = useState<Record<string, ServerTestState>>({});
|
||||||
|
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||||
|
const [jsonEditServer, setJsonEditServer] = useState<MCPServerConfig | null>(null);
|
||||||
|
const [jsonEditValue, setJsonEditValue] = useState('');
|
||||||
|
const [isGlobalJsonEditOpen, setIsGlobalJsonEditOpen] = useState(false);
|
||||||
|
const [globalJsonValue, setGlobalJsonValue] = useState('');
|
||||||
|
const autoTestedServersRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
// Security warning dialog state
|
||||||
|
const [isSecurityWarningOpen, setIsSecurityWarningOpen] = useState(false);
|
||||||
|
const [pendingServerData, setPendingServerData] = useState<PendingServerData | null>(null);
|
||||||
|
|
||||||
|
// Computed values
|
||||||
|
const totalToolsCount = useMemo(() => {
|
||||||
|
let count = 0;
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
if (server.enabled !== false) {
|
||||||
|
const testState = serverTestStates[server.id];
|
||||||
|
if (testState?.status === 'success' && testState.tools) {
|
||||||
|
count += testState.tools.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count;
|
||||||
|
}, [mcpServers, serverTestStates]);
|
||||||
|
|
||||||
|
const showToolsWarning = totalToolsCount > MAX_RECOMMENDED_TOOLS;
|
||||||
|
|
||||||
|
// Auto-load MCP servers from settings file on mount
|
||||||
|
useEffect(() => {
|
||||||
|
loadMCPServersFromServer().catch((error) => {
|
||||||
|
console.error('Failed to load MCP servers on mount:', error);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Test a single server (extracted for reuse)
|
||||||
|
const testServer = useCallback(async (server: MCPServerConfig, silent = false) => {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: { status: 'testing' },
|
||||||
|
}));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.mcp.testServer(server.id);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'success',
|
||||||
|
tools: result.tools,
|
||||||
|
connectionTime: result.connectionTime,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
// Only auto-expand on manual test, not on auto-test (silent)
|
||||||
|
if (!silent) {
|
||||||
|
setExpandedServers((prev) => new Set([...prev, server.id]));
|
||||||
|
toast.success(
|
||||||
|
`Connected to ${server.name} (${result.tools?.length || 0} tools, ${result.connectionTime}ms)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'error',
|
||||||
|
error: result.error,
|
||||||
|
connectionTime: result.connectionTime,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!silent) {
|
||||||
|
toast.error(`Failed to connect: ${result.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
setServerTestStates((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[server.id]: {
|
||||||
|
status: 'error',
|
||||||
|
error: errorMessage,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
if (!silent) {
|
||||||
|
toast.error(`Test failed: ${errorMessage}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-test all enabled servers on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const enabledServers = mcpServers.filter((s) => s.enabled !== false);
|
||||||
|
const serversToTest = enabledServers.filter((s) => !autoTestedServersRef.current.has(s.id));
|
||||||
|
|
||||||
|
if (serversToTest.length > 0) {
|
||||||
|
// Mark all as being tested
|
||||||
|
serversToTest.forEach((s) => autoTestedServersRef.current.add(s.id));
|
||||||
|
|
||||||
|
// Test all servers in parallel (silently - no toast spam)
|
||||||
|
serversToTest.forEach((server) => {
|
||||||
|
testServer(server, true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [mcpServers, testServer]);
|
||||||
|
|
||||||
|
const handleRefresh = async () => {
|
||||||
|
setIsRefreshing(true);
|
||||||
|
try {
|
||||||
|
const success = await loadMCPServersFromServer();
|
||||||
|
if (success) {
|
||||||
|
toast.success('MCP servers refreshed from settings');
|
||||||
|
} else {
|
||||||
|
toast.error('Failed to refresh MCP servers');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Error refreshing MCP servers');
|
||||||
|
} finally {
|
||||||
|
setIsRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTestServer = (server: MCPServerConfig) => {
|
||||||
|
testServer(server, false); // false = show toast notifications
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleServerExpanded = (serverId: string) => {
|
||||||
|
setExpandedServers((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(serverId)) {
|
||||||
|
next.delete(serverId);
|
||||||
|
} else {
|
||||||
|
next.add(serverId);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenAddDialog = () => {
|
||||||
|
setFormData(defaultFormData);
|
||||||
|
setEditingServer(null);
|
||||||
|
setIsAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenEditDialog = (server: MCPServerConfig) => {
|
||||||
|
setFormData({
|
||||||
|
name: server.name,
|
||||||
|
description: server.description || '',
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
command: server.command || '',
|
||||||
|
args: server.args?.join(' ') || '',
|
||||||
|
url: server.url || '',
|
||||||
|
headers: server.headers ? JSON.stringify(server.headers, null, 2) : '',
|
||||||
|
env: server.env ? JSON.stringify(server.env, null, 2) : '',
|
||||||
|
});
|
||||||
|
setEditingServer(server);
|
||||||
|
setIsAddDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCloseDialog = () => {
|
||||||
|
setIsAddDialogOpen(false);
|
||||||
|
setEditingServer(null);
|
||||||
|
setFormData(defaultFormData);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!formData.name.trim()) {
|
||||||
|
toast.error('Server name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (formData.type === 'stdio' && !formData.command.trim()) {
|
||||||
|
toast.error('Command is required for stdio servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((formData.type === 'sse' || formData.type === 'http') && !formData.url.trim()) {
|
||||||
|
toast.error('URL is required for SSE/HTTP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse headers if provided
|
||||||
|
let parsedHeaders: Record<string, string> | undefined;
|
||||||
|
if (formData.headers.trim()) {
|
||||||
|
try {
|
||||||
|
parsedHeaders = JSON.parse(formData.headers.trim());
|
||||||
|
if (typeof parsedHeaders !== 'object' || Array.isArray(parsedHeaders)) {
|
||||||
|
toast.error('Headers must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Invalid JSON for headers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse env if provided
|
||||||
|
let parsedEnv: Record<string, string> | undefined;
|
||||||
|
if (formData.env.trim()) {
|
||||||
|
try {
|
||||||
|
parsedEnv = JSON.parse(formData.env.trim());
|
||||||
|
if (typeof parsedEnv !== 'object' || Array.isArray(parsedEnv)) {
|
||||||
|
toast.error('Environment variables must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('Invalid JSON for environment variables');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name: formData.name.trim(),
|
||||||
|
description: formData.description.trim() || undefined,
|
||||||
|
type: formData.type,
|
||||||
|
enabled: editingServer?.enabled ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (formData.type === 'stdio') {
|
||||||
|
serverData.command = formData.command.trim();
|
||||||
|
if (formData.args.trim()) {
|
||||||
|
serverData.args = formData.args.trim().split(/\s+/);
|
||||||
|
}
|
||||||
|
if (parsedEnv) {
|
||||||
|
serverData.env = parsedEnv;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverData.url = formData.url.trim();
|
||||||
|
if (parsedHeaders) {
|
||||||
|
serverData.headers = parsedHeaders;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If editing an existing server, save directly (user already approved it)
|
||||||
|
if (editingServer) {
|
||||||
|
updateMCPServer(editingServer.id, serverData);
|
||||||
|
toast.success('MCP server updated');
|
||||||
|
await syncSettingsToServer();
|
||||||
|
handleCloseDialog();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// For new servers, show security warning first
|
||||||
|
setPendingServerData({
|
||||||
|
type: 'add',
|
||||||
|
serverData,
|
||||||
|
serverType: formData.type,
|
||||||
|
command: formData.type === 'stdio' ? formData.command.trim() : undefined,
|
||||||
|
args:
|
||||||
|
formData.type === 'stdio' && formData.args.trim()
|
||||||
|
? formData.args.trim().split(/\s+/)
|
||||||
|
: undefined,
|
||||||
|
url: formData.type !== 'stdio' ? formData.url.trim() : undefined,
|
||||||
|
});
|
||||||
|
setIsSecurityWarningOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Called when user confirms the security warning for adding a server */
|
||||||
|
const handleSecurityWarningConfirm = async () => {
|
||||||
|
if (!pendingServerData) return;
|
||||||
|
|
||||||
|
if (pendingServerData.type === 'add' && pendingServerData.serverData) {
|
||||||
|
addMCPServer(pendingServerData.serverData);
|
||||||
|
toast.success('MCP server added');
|
||||||
|
await syncSettingsToServer();
|
||||||
|
handleCloseDialog();
|
||||||
|
} else if (pendingServerData.type === 'import' && pendingServerData.importServers) {
|
||||||
|
for (const serverData of pendingServerData.importServers) {
|
||||||
|
addMCPServer(serverData);
|
||||||
|
}
|
||||||
|
await syncSettingsToServer();
|
||||||
|
const count = pendingServerData.importServers.length;
|
||||||
|
toast.success(`Imported ${count} MCP server${count > 1 ? 's' : ''}`);
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setImportJson('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSecurityWarningOpen(false);
|
||||||
|
setPendingServerData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleEnabled = async (server: MCPServerConfig) => {
|
||||||
|
updateMCPServer(server.id, { enabled: !server.enabled });
|
||||||
|
await syncSettingsToServer();
|
||||||
|
toast.success(server.enabled ? 'Server disabled' : 'Server enabled');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (id: string) => {
|
||||||
|
removeMCPServer(id);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
setDeleteConfirmId(null);
|
||||||
|
toast.success('MCP server removed');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImportJson = async () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(importJson);
|
||||||
|
|
||||||
|
// Support both formats:
|
||||||
|
// 1. Claude Code format: { "mcpServers": { "name": { command, args, ... } } }
|
||||||
|
// 2. Direct format: { "name": { command, args, ... } }
|
||||||
|
const servers = parsed.mcpServers || parsed;
|
||||||
|
|
||||||
|
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||||
|
toast.error('Invalid format: expected object with server configurations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serversToImport: Array<Omit<MCPServerConfig, 'id'>> = [];
|
||||||
|
let skippedCount = 0;
|
||||||
|
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
if (typeof config !== 'object' || config === null) continue;
|
||||||
|
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
|
||||||
|
// Check if server with this name already exists
|
||||||
|
if (mcpServers.some((s) => s.name === name)) {
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name,
|
||||||
|
type: (serverConfig.type as ServerType) || 'stdio',
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverData.type === 'stdio') {
|
||||||
|
if (!serverConfig.command) {
|
||||||
|
console.warn(`Skipping ${name}: no command specified`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawCommand = serverConfig.command as string;
|
||||||
|
|
||||||
|
// Support both formats:
|
||||||
|
// 1. Separate command/args: { "command": "npx", "args": ["-y", "package"] }
|
||||||
|
// 2. Inline args (Claude Desktop format): { "command": "npx -y package" }
|
||||||
|
if (Array.isArray(serverConfig.args) && serverConfig.args.length > 0) {
|
||||||
|
// Args provided separately
|
||||||
|
serverData.command = rawCommand;
|
||||||
|
serverData.args = serverConfig.args as string[];
|
||||||
|
} else if (rawCommand.includes(' ')) {
|
||||||
|
// Parse inline command string - split on spaces but preserve quoted strings
|
||||||
|
const parts = rawCommand.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [rawCommand];
|
||||||
|
serverData.command = parts[0];
|
||||||
|
if (parts.length > 1) {
|
||||||
|
// Remove quotes from args
|
||||||
|
serverData.args = parts.slice(1).map((arg) => arg.replace(/^["']|["']$/g, ''));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverData.command = rawCommand;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||||
|
serverData.env = serverConfig.env as Record<string, string>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!serverConfig.url) {
|
||||||
|
console.warn(`Skipping ${name}: no url specified`);
|
||||||
|
skippedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
serverData.url = serverConfig.url as string;
|
||||||
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||||
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
serversToImport.push(serverData);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (skippedCount > 0) {
|
||||||
|
toast.info(
|
||||||
|
`Skipped ${skippedCount} server${skippedCount > 1 ? 's' : ''} (already exist or invalid)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serversToImport.length === 0) {
|
||||||
|
toast.warning('No new servers to import');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show security warning before importing
|
||||||
|
// Use the first server's type for the warning (most imports are stdio)
|
||||||
|
const firstServer = serversToImport[0];
|
||||||
|
setPendingServerData({
|
||||||
|
type: 'import',
|
||||||
|
importServers: serversToImport,
|
||||||
|
serverType: firstServer.type || 'stdio',
|
||||||
|
command: firstServer.type === 'stdio' ? firstServer.command : undefined,
|
||||||
|
args: firstServer.type === 'stdio' ? firstServer.args : undefined,
|
||||||
|
url: firstServer.type !== 'stdio' ? firstServer.url : undefined,
|
||||||
|
});
|
||||||
|
setIsSecurityWarningOpen(true);
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleExportJson = () => {
|
||||||
|
const exportData: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
const serverConfig: Record<string, unknown> = {
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
serverConfig.command = server.command;
|
||||||
|
if (server.args?.length) serverConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
serverConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0)
|
||||||
|
serverConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[server.name] = serverConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
const json = JSON.stringify({ mcpServers: exportData }, null, 2);
|
||||||
|
navigator.clipboard.writeText(json);
|
||||||
|
toast.success('Copied to clipboard');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenJsonEdit = (server: MCPServerConfig) => {
|
||||||
|
// Build a clean config object for editing (excluding internal fields like id)
|
||||||
|
const editableConfig: Record<string, unknown> = {
|
||||||
|
name: server.name,
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.description) {
|
||||||
|
editableConfig.description = server.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
if (server.command) editableConfig.command = server.command;
|
||||||
|
if (server.args?.length) editableConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) editableConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
if (server.url) editableConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||||
|
editableConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.enabled === false) {
|
||||||
|
editableConfig.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setJsonEditValue(JSON.stringify(editableConfig, null, 2));
|
||||||
|
setJsonEditServer(server);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveJsonEdit = async () => {
|
||||||
|
if (!jsonEditServer) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonEditValue);
|
||||||
|
|
||||||
|
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
|
toast.error('Config must be a JSON object');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields based on type
|
||||||
|
const serverType = parsed.type || 'stdio';
|
||||||
|
|
||||||
|
if (!parsed.name || typeof parsed.name !== 'string') {
|
||||||
|
toast.error('Name is required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
if (!parsed.command || typeof parsed.command !== 'string') {
|
||||||
|
toast.error('Command is required for stdio servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (serverType === 'sse' || serverType === 'http') {
|
||||||
|
if (!parsed.url || typeof parsed.url !== 'string') {
|
||||||
|
toast.error('URL is required for SSE/HTTP servers');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build update object
|
||||||
|
const updateData: Partial<MCPServerConfig> = {
|
||||||
|
name: parsed.name,
|
||||||
|
type: serverType,
|
||||||
|
description: parsed.description || undefined,
|
||||||
|
enabled: parsed.enabled !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
updateData.command = parsed.command;
|
||||||
|
updateData.args = Array.isArray(parsed.args) ? parsed.args : undefined;
|
||||||
|
updateData.env =
|
||||||
|
typeof parsed.env === 'object' && !Array.isArray(parsed.env) ? parsed.env : undefined;
|
||||||
|
// Clear HTTP fields
|
||||||
|
updateData.url = undefined;
|
||||||
|
updateData.headers = undefined;
|
||||||
|
} else {
|
||||||
|
updateData.url = parsed.url;
|
||||||
|
updateData.headers =
|
||||||
|
typeof parsed.headers === 'object' && !Array.isArray(parsed.headers)
|
||||||
|
? parsed.headers
|
||||||
|
: undefined;
|
||||||
|
// Clear stdio fields
|
||||||
|
updateData.command = undefined;
|
||||||
|
updateData.args = undefined;
|
||||||
|
updateData.env = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateMCPServer(jsonEditServer.id, updateData);
|
||||||
|
await syncSettingsToServer();
|
||||||
|
|
||||||
|
toast.success('Server configuration updated');
|
||||||
|
setJsonEditServer(null);
|
||||||
|
setJsonEditValue('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleOpenGlobalJsonEdit = () => {
|
||||||
|
// Build the full mcpServers config object
|
||||||
|
const exportData: Record<string, Record<string, unknown>> = {};
|
||||||
|
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
const serverConfig: Record<string, unknown> = {
|
||||||
|
type: server.type || 'stdio',
|
||||||
|
};
|
||||||
|
|
||||||
|
if (server.description) {
|
||||||
|
serverConfig.description = server.description;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.enabled === false) {
|
||||||
|
serverConfig.enabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (server.type === 'stdio' || !server.type) {
|
||||||
|
serverConfig.command = server.command;
|
||||||
|
if (server.args?.length) serverConfig.args = server.args;
|
||||||
|
if (server.env && Object.keys(server.env).length > 0) serverConfig.env = server.env;
|
||||||
|
} else {
|
||||||
|
serverConfig.url = server.url;
|
||||||
|
if (server.headers && Object.keys(server.headers).length > 0) {
|
||||||
|
serverConfig.headers = server.headers;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
exportData[server.name] = serverConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
setGlobalJsonValue(JSON.stringify({ mcpServers: exportData }, null, 2));
|
||||||
|
setIsGlobalJsonEditOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveGlobalJsonEdit = async () => {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(globalJsonValue);
|
||||||
|
|
||||||
|
// Support both formats
|
||||||
|
const servers = parsed.mcpServers || parsed;
|
||||||
|
|
||||||
|
if (typeof servers !== 'object' || Array.isArray(servers)) {
|
||||||
|
toast.error('Invalid format: expected object with server configurations');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate all servers first
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
if (typeof config !== 'object' || config === null) {
|
||||||
|
toast.error(`Invalid config for "${name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
const serverType = (serverConfig.type as string) || 'stdio';
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
if (!serverConfig.command || typeof serverConfig.command !== 'string') {
|
||||||
|
toast.error(`Command is required for "${name}" (stdio)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (serverType === 'sse' || serverType === 'http') {
|
||||||
|
if (!serverConfig.url || typeof serverConfig.url !== 'string') {
|
||||||
|
toast.error(`URL is required for "${name}" (${serverType})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a map of existing servers by name for updating
|
||||||
|
const existingByName = new Map(mcpServers.map((s) => [s.name, s]));
|
||||||
|
const processedNames = new Set<string>();
|
||||||
|
|
||||||
|
// Update or add servers
|
||||||
|
for (const [name, config] of Object.entries(servers)) {
|
||||||
|
const serverConfig = config as Record<string, unknown>;
|
||||||
|
const serverType = (serverConfig.type as ServerType) || 'stdio';
|
||||||
|
|
||||||
|
const serverData: Omit<MCPServerConfig, 'id'> = {
|
||||||
|
name,
|
||||||
|
type: serverType,
|
||||||
|
description: (serverConfig.description as string) || undefined,
|
||||||
|
enabled: serverConfig.enabled !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (serverType === 'stdio') {
|
||||||
|
serverData.command = serverConfig.command as string;
|
||||||
|
if (Array.isArray(serverConfig.args)) {
|
||||||
|
serverData.args = serverConfig.args as string[];
|
||||||
|
}
|
||||||
|
if (typeof serverConfig.env === 'object' && serverConfig.env !== null) {
|
||||||
|
serverData.env = serverConfig.env as Record<string, string>;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
serverData.url = serverConfig.url as string;
|
||||||
|
if (typeof serverConfig.headers === 'object' && serverConfig.headers !== null) {
|
||||||
|
serverData.headers = serverConfig.headers as Record<string, string>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = existingByName.get(name);
|
||||||
|
if (existing) {
|
||||||
|
updateMCPServer(existing.id, serverData);
|
||||||
|
} else {
|
||||||
|
addMCPServer(serverData);
|
||||||
|
}
|
||||||
|
processedNames.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove servers that are no longer in the JSON
|
||||||
|
for (const server of mcpServers) {
|
||||||
|
if (!processedNames.has(server.name)) {
|
||||||
|
removeMCPServer(server.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncSettingsToServer();
|
||||||
|
|
||||||
|
toast.success('MCP servers configuration updated');
|
||||||
|
setIsGlobalJsonEditOpen(false);
|
||||||
|
setGlobalJsonValue('');
|
||||||
|
} catch (error) {
|
||||||
|
toast.error('Invalid JSON: ' + (error instanceof Error ? error.message : 'Parse error'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Store state
|
||||||
|
mcpServers,
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
setMcpAutoApproveTools,
|
||||||
|
setMcpUnrestrictedTools,
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
isAddDialogOpen,
|
||||||
|
setIsAddDialogOpen,
|
||||||
|
editingServer,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
isImportDialogOpen,
|
||||||
|
setIsImportDialogOpen,
|
||||||
|
importJson,
|
||||||
|
setImportJson,
|
||||||
|
jsonEditServer,
|
||||||
|
setJsonEditServer,
|
||||||
|
jsonEditValue,
|
||||||
|
setJsonEditValue,
|
||||||
|
isGlobalJsonEditOpen,
|
||||||
|
setIsGlobalJsonEditOpen,
|
||||||
|
globalJsonValue,
|
||||||
|
setGlobalJsonValue,
|
||||||
|
|
||||||
|
// Security warning dialog state
|
||||||
|
isSecurityWarningOpen,
|
||||||
|
setIsSecurityWarningOpen,
|
||||||
|
pendingServerData,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isRefreshing,
|
||||||
|
serverTestStates,
|
||||||
|
expandedServers,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalToolsCount,
|
||||||
|
showToolsWarning,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleRefresh,
|
||||||
|
handleTestServer,
|
||||||
|
toggleServerExpanded,
|
||||||
|
handleOpenAddDialog,
|
||||||
|
handleOpenEditDialog,
|
||||||
|
handleCloseDialog,
|
||||||
|
handleSave,
|
||||||
|
handleToggleEnabled,
|
||||||
|
handleDelete,
|
||||||
|
handleImportJson,
|
||||||
|
handleExportJson,
|
||||||
|
handleOpenJsonEdit,
|
||||||
|
handleSaveJsonEdit,
|
||||||
|
handleOpenGlobalJsonEdit,
|
||||||
|
handleSaveGlobalJsonEdit,
|
||||||
|
handleSecurityWarningConfirm,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { MCPServersSection } from './mcp-servers-section';
|
||||||
|
export { MCPToolsList, type MCPToolDisplay } from './mcp-tools-list';
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { Plug } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { useMCPServers } from './hooks';
|
||||||
|
import {
|
||||||
|
MCPServerHeader,
|
||||||
|
MCPPermissionSettings,
|
||||||
|
MCPToolsWarning,
|
||||||
|
MCPServerCard,
|
||||||
|
} from './components';
|
||||||
|
import {
|
||||||
|
AddEditServerDialog,
|
||||||
|
DeleteServerDialog,
|
||||||
|
ImportJsonDialog,
|
||||||
|
JsonEditDialog,
|
||||||
|
GlobalJsonEditDialog,
|
||||||
|
SecurityWarningDialog,
|
||||||
|
} from './dialogs';
|
||||||
|
|
||||||
|
export function MCPServersSection() {
|
||||||
|
const {
|
||||||
|
// Store state
|
||||||
|
mcpServers,
|
||||||
|
mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools,
|
||||||
|
setMcpAutoApproveTools,
|
||||||
|
setMcpUnrestrictedTools,
|
||||||
|
|
||||||
|
// Dialog state
|
||||||
|
isAddDialogOpen,
|
||||||
|
setIsAddDialogOpen,
|
||||||
|
editingServer,
|
||||||
|
formData,
|
||||||
|
setFormData,
|
||||||
|
deleteConfirmId,
|
||||||
|
setDeleteConfirmId,
|
||||||
|
isImportDialogOpen,
|
||||||
|
setIsImportDialogOpen,
|
||||||
|
importJson,
|
||||||
|
setImportJson,
|
||||||
|
jsonEditServer,
|
||||||
|
setJsonEditServer,
|
||||||
|
jsonEditValue,
|
||||||
|
setJsonEditValue,
|
||||||
|
isGlobalJsonEditOpen,
|
||||||
|
setIsGlobalJsonEditOpen,
|
||||||
|
globalJsonValue,
|
||||||
|
setGlobalJsonValue,
|
||||||
|
|
||||||
|
// Security warning dialog state
|
||||||
|
isSecurityWarningOpen,
|
||||||
|
setIsSecurityWarningOpen,
|
||||||
|
pendingServerData,
|
||||||
|
|
||||||
|
// UI state
|
||||||
|
isRefreshing,
|
||||||
|
serverTestStates,
|
||||||
|
expandedServers,
|
||||||
|
|
||||||
|
// Computed
|
||||||
|
totalToolsCount,
|
||||||
|
showToolsWarning,
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
handleRefresh,
|
||||||
|
handleTestServer,
|
||||||
|
toggleServerExpanded,
|
||||||
|
handleOpenAddDialog,
|
||||||
|
handleOpenEditDialog,
|
||||||
|
handleCloseDialog,
|
||||||
|
handleSave,
|
||||||
|
handleToggleEnabled,
|
||||||
|
handleDelete,
|
||||||
|
handleImportJson,
|
||||||
|
handleExportJson,
|
||||||
|
handleOpenJsonEdit,
|
||||||
|
handleSaveJsonEdit,
|
||||||
|
handleOpenGlobalJsonEdit,
|
||||||
|
handleSaveGlobalJsonEdit,
|
||||||
|
handleSecurityWarningConfirm,
|
||||||
|
} = useMCPServers();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-2xl overflow-hidden',
|
||||||
|
'border border-border/50',
|
||||||
|
'bg-linear-to-br from-card/90 via-card/70 to-card/80 backdrop-blur-xl',
|
||||||
|
'shadow-sm shadow-black/5'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MCPServerHeader
|
||||||
|
isRefreshing={isRefreshing}
|
||||||
|
hasServers={mcpServers.length > 0}
|
||||||
|
onRefresh={handleRefresh}
|
||||||
|
onExport={handleExportJson}
|
||||||
|
onEditAllJson={handleOpenGlobalJsonEdit}
|
||||||
|
onImport={() => setIsImportDialogOpen(true)}
|
||||||
|
onAdd={handleOpenAddDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mcpServers.length > 0 && (
|
||||||
|
<MCPPermissionSettings
|
||||||
|
mcpAutoApproveTools={mcpAutoApproveTools}
|
||||||
|
mcpUnrestrictedTools={mcpUnrestrictedTools}
|
||||||
|
onAutoApproveChange={setMcpAutoApproveTools}
|
||||||
|
onUnrestrictedChange={setMcpUnrestrictedTools}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showToolsWarning && <MCPToolsWarning totalTools={totalToolsCount} />}
|
||||||
|
|
||||||
|
<div className="p-6">
|
||||||
|
{mcpServers.length === 0 ? (
|
||||||
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
|
<Plug className="w-12 h-12 mx-auto mb-3 opacity-50" />
|
||||||
|
<p className="text-sm">No MCP servers configured</p>
|
||||||
|
<p className="text-xs mt-1">Add a server to extend agent capabilities</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mcpServers.map((server) => (
|
||||||
|
<MCPServerCard
|
||||||
|
key={server.id}
|
||||||
|
server={server}
|
||||||
|
testState={serverTestStates[server.id]}
|
||||||
|
isExpanded={expandedServers.has(server.id)}
|
||||||
|
onToggleExpanded={() => toggleServerExpanded(server.id)}
|
||||||
|
onTest={() => handleTestServer(server)}
|
||||||
|
onToggleEnabled={() => handleToggleEnabled(server)}
|
||||||
|
onEditJson={() => handleOpenJsonEdit(server)}
|
||||||
|
onEdit={() => handleOpenEditDialog(server)}
|
||||||
|
onDelete={() => setDeleteConfirmId(server.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
<AddEditServerDialog
|
||||||
|
open={isAddDialogOpen}
|
||||||
|
onOpenChange={setIsAddDialogOpen}
|
||||||
|
editingServer={editingServer}
|
||||||
|
formData={formData}
|
||||||
|
onFormDataChange={setFormData}
|
||||||
|
onSave={handleSave}
|
||||||
|
onCancel={handleCloseDialog}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DeleteServerDialog
|
||||||
|
open={!!deleteConfirmId}
|
||||||
|
onOpenChange={(open) => setDeleteConfirmId(open ? deleteConfirmId : null)}
|
||||||
|
onConfirm={() => deleteConfirmId && handleDelete(deleteConfirmId)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ImportJsonDialog
|
||||||
|
open={isImportDialogOpen}
|
||||||
|
onOpenChange={setIsImportDialogOpen}
|
||||||
|
importJson={importJson}
|
||||||
|
onImportJsonChange={setImportJson}
|
||||||
|
onImport={handleImportJson}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsImportDialogOpen(false);
|
||||||
|
setImportJson('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<JsonEditDialog
|
||||||
|
open={!!jsonEditServer}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setJsonEditServer(null);
|
||||||
|
setJsonEditValue('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
server={jsonEditServer}
|
||||||
|
jsonValue={jsonEditValue}
|
||||||
|
onJsonValueChange={setJsonEditValue}
|
||||||
|
onSave={handleSaveJsonEdit}
|
||||||
|
onCancel={() => {
|
||||||
|
setJsonEditServer(null);
|
||||||
|
setJsonEditValue('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<GlobalJsonEditDialog
|
||||||
|
open={isGlobalJsonEditOpen}
|
||||||
|
onOpenChange={setIsGlobalJsonEditOpen}
|
||||||
|
jsonValue={globalJsonValue}
|
||||||
|
onJsonValueChange={setGlobalJsonValue}
|
||||||
|
onSave={handleSaveGlobalJsonEdit}
|
||||||
|
onCancel={() => {
|
||||||
|
setIsGlobalJsonEditOpen(false);
|
||||||
|
setGlobalJsonValue('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SecurityWarningDialog
|
||||||
|
open={isSecurityWarningOpen}
|
||||||
|
onOpenChange={setIsSecurityWarningOpen}
|
||||||
|
onConfirm={handleSecurityWarningConfirm}
|
||||||
|
serverType={pendingServerData?.serverType || 'stdio'}
|
||||||
|
serverName={pendingServerData?.serverData?.name || ''}
|
||||||
|
command={pendingServerData?.command}
|
||||||
|
args={pendingServerData?.args}
|
||||||
|
url={pendingServerData?.url}
|
||||||
|
importCount={
|
||||||
|
pendingServerData?.type === 'import' ? pendingServerData.importServers?.length : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { ChevronDown, ChevronRight, Wrench } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
|
|
||||||
|
export interface MCPToolDisplay {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MCPToolsListProps {
|
||||||
|
tools: MCPToolDisplay[];
|
||||||
|
isLoading?: boolean;
|
||||||
|
error?: string;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MCPToolsList({ tools, isLoading, error, className }: MCPToolsListProps) {
|
||||||
|
const [expandedTools, setExpandedTools] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const toggleTool = (toolName: string) => {
|
||||||
|
setExpandedTools((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(toolName)) {
|
||||||
|
next.delete(toolName);
|
||||||
|
} else {
|
||||||
|
next.add(toolName);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-sm text-muted-foreground animate-pulse', className)}>
|
||||||
|
Loading tools...
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div className={cn('text-sm text-destructive wrap-break-word', className)}>{error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!tools || tools.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={cn('text-sm text-muted-foreground italic', className)}>
|
||||||
|
No tools available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={cn('space-y-1 overflow-hidden', className)}>
|
||||||
|
{tools.map((tool) => {
|
||||||
|
const isExpanded = expandedTools.has(tool.name);
|
||||||
|
const hasSchema = tool.inputSchema && Object.keys(tool.inputSchema).length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Collapsible key={tool.name} open={isExpanded} onOpenChange={() => toggleTool(tool.name)}>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg border border-border/30 bg-background/50 overflow-hidden',
|
||||||
|
'hover:border-border/50 transition-colors'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<CollapsibleTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start h-auto py-2 px-3 font-normal"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 w-full min-w-0 overflow-hidden">
|
||||||
|
<div className="flex items-center gap-1.5 shrink-0 mt-0.5">
|
||||||
|
{hasSchema ? (
|
||||||
|
isExpanded ? (
|
||||||
|
<ChevronDown className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground" />
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<div className="w-3.5" />
|
||||||
|
)}
|
||||||
|
<Wrench className="w-3.5 h-3.5 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-start text-left min-w-0 overflow-hidden flex-1">
|
||||||
|
<span className="font-medium text-xs truncate max-w-full">{tool.name}</span>
|
||||||
|
{tool.description && (
|
||||||
|
<span className="text-xs text-muted-foreground line-clamp-2 wrap-break-word w-full">
|
||||||
|
{tool.description}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</CollapsibleTrigger>
|
||||||
|
{hasSchema && (
|
||||||
|
<CollapsibleContent>
|
||||||
|
<div className="px-3 pb-2 pt-0 overflow-hidden">
|
||||||
|
<div className="bg-muted/50 rounded p-2 text-xs font-mono overflow-x-auto max-h-48">
|
||||||
|
<pre className="whitespace-pre-wrap break-all text-[10px] leading-relaxed">
|
||||||
|
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CollapsibleContent>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Collapsible>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { MCPToolDisplay } from './mcp-tools-list';
|
||||||
|
|
||||||
|
export type ServerType = 'stdio' | 'sse' | 'http';
|
||||||
|
|
||||||
|
export interface ServerFormData {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
type: ServerType;
|
||||||
|
command: string;
|
||||||
|
args: string;
|
||||||
|
url: string;
|
||||||
|
headers: string; // JSON string for headers
|
||||||
|
env: string; // JSON string for env vars
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultFormData: ServerFormData = {
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
type: 'stdio',
|
||||||
|
command: '',
|
||||||
|
args: '',
|
||||||
|
url: '',
|
||||||
|
headers: '',
|
||||||
|
env: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ServerTestState {
|
||||||
|
status: 'idle' | 'testing' | 'success' | 'error';
|
||||||
|
tools?: MCPToolDisplay[];
|
||||||
|
error?: string;
|
||||||
|
connectionTime?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { Terminal, Globe, Loader2, CheckCircle2, XCircle } from 'lucide-react';
|
||||||
|
import type { ServerType, ServerTestState } from './types';
|
||||||
|
import { SENSITIVE_PARAM_PATTERNS } from './constants';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mask sensitive values in URLs (query params with key-like names)
|
||||||
|
*/
|
||||||
|
export function maskSensitiveUrl(url: string): string {
|
||||||
|
try {
|
||||||
|
const urlObj = new URL(url);
|
||||||
|
const params = new URLSearchParams(urlObj.search);
|
||||||
|
let hasSensitive = false;
|
||||||
|
|
||||||
|
for (const [key] of params.entries()) {
|
||||||
|
if (SENSITIVE_PARAM_PATTERNS.some((pattern) => pattern.test(key))) {
|
||||||
|
params.set(key, '***');
|
||||||
|
hasSensitive = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hasSensitive) {
|
||||||
|
urlObj.search = params.toString();
|
||||||
|
return urlObj.toString();
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
} catch {
|
||||||
|
// If URL parsing fails, try simple regex replacement for common patterns
|
||||||
|
return url.replace(
|
||||||
|
/([?&])(api[-_]?key|auth|token|secret|password|credential)=([^&]*)/gi,
|
||||||
|
'$1$2=***'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getServerIcon(type: ServerType = 'stdio') {
|
||||||
|
if (type === 'stdio') return Terminal;
|
||||||
|
return Globe;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTestStatusIcon(status: ServerTestState['status']) {
|
||||||
|
switch (status) {
|
||||||
|
case 'testing':
|
||||||
|
return <Loader2 className="w-4 h-4 animate-spin text-brand-500" />;
|
||||||
|
case 'success':
|
||||||
|
return <CheckCircle2 className="w-4 h-4 text-green-500" />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle className="w-4 h-4 text-destructive" />;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { useEffect, useState, useRef } from 'react';
|
|||||||
import { getHttpApiClient } from '@/lib/http-api-client';
|
import { getHttpApiClient } from '@/lib/http-api-client';
|
||||||
import { isElectron } from '@/lib/electron';
|
import { isElectron } from '@/lib/electron';
|
||||||
import { getItem, removeItem } from '@/lib/storage';
|
import { getItem, removeItem } from '@/lib/storage';
|
||||||
|
import { useAppStore } from '@/store/app-store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* State returned by useSettingsMigration hook
|
* State returned by useSettingsMigration hook
|
||||||
@@ -227,6 +228,9 @@ export async function syncSettingsToServer(): Promise<boolean> {
|
|||||||
enableSandboxMode: state.enableSandboxMode,
|
enableSandboxMode: state.enableSandboxMode,
|
||||||
keyboardShortcuts: state.keyboardShortcuts,
|
keyboardShortcuts: state.keyboardShortcuts,
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
|
mcpServers: state.mcpServers,
|
||||||
|
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||||
projects: state.projects,
|
projects: state.projects,
|
||||||
trashedProjects: state.trashedProjects,
|
trashedProjects: state.trashedProjects,
|
||||||
projectHistory: state.projectHistory,
|
projectHistory: state.projectHistory,
|
||||||
@@ -316,3 +320,42 @@ export async function syncProjectSettingsToServer(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load MCP servers from server settings file into the store
|
||||||
|
*
|
||||||
|
* Fetches the global settings from the server and updates the store's
|
||||||
|
* mcpServers state. Useful when settings were modified externally
|
||||||
|
* (e.g., by editing the settings.json file directly).
|
||||||
|
*
|
||||||
|
* Only functions in Electron mode. Returns false if not in Electron or on error.
|
||||||
|
*
|
||||||
|
* @returns Promise resolving to true if load succeeded, false otherwise
|
||||||
|
*/
|
||||||
|
export async function loadMCPServersFromServer(): Promise<boolean> {
|
||||||
|
if (!isElectron()) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const api = getHttpApiClient();
|
||||||
|
const result = await api.settings.getGlobal();
|
||||||
|
|
||||||
|
if (!result.success || !result.settings) {
|
||||||
|
console.error('[Settings Load] Failed to load settings:', result.error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mcpServers = result.settings.mcpServers || [];
|
||||||
|
const mcpAutoApproveTools = result.settings.mcpAutoApproveTools ?? true;
|
||||||
|
const mcpUnrestrictedTools = result.settings.mcpUnrestrictedTools ?? true;
|
||||||
|
|
||||||
|
// Clear existing and add all from server
|
||||||
|
// We need to update the store directly since we can't use hooks here
|
||||||
|
useAppStore.setState({ mcpServers, mcpAutoApproveTools, mcpUnrestrictedTools });
|
||||||
|
|
||||||
|
console.log(`[Settings Load] Loaded ${mcpServers.length} MCP servers from server`);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[Settings Load] Failed to load MCP servers:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -928,6 +928,20 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
recentFolders: string[];
|
recentFolders: string[];
|
||||||
worktreePanelCollapsed: boolean;
|
worktreePanelCollapsed: boolean;
|
||||||
lastSelectedSessionByProject: Record<string, string>;
|
lastSelectedSessionByProject: Record<string, string>;
|
||||||
|
mcpServers?: Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
type?: 'stdio' | 'sse' | 'http';
|
||||||
|
command?: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
url?: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
enabled?: boolean;
|
||||||
|
}>;
|
||||||
|
mcpAutoApproveTools?: boolean;
|
||||||
|
mcpUnrestrictedTools?: boolean;
|
||||||
};
|
};
|
||||||
error?: string;
|
error?: string;
|
||||||
}> => this.get('/api/settings/global'),
|
}> => this.get('/api/settings/global'),
|
||||||
@@ -1128,6 +1142,42 @@ export class HttpApiClient implements ElectronAPI {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// MCP API - Test MCP server connections and list tools
|
||||||
|
// SECURITY: Only accepts serverId, not arbitrary serverConfig, to prevent
|
||||||
|
// drive-by command execution attacks. Servers must be saved first.
|
||||||
|
mcp = {
|
||||||
|
testServer: (
|
||||||
|
serverId: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
tools?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
connectionTime?: number;
|
||||||
|
serverInfo?: {
|
||||||
|
name?: string;
|
||||||
|
version?: string;
|
||||||
|
};
|
||||||
|
}> => this.post('/api/mcp/test', { serverId }),
|
||||||
|
|
||||||
|
listTools: (
|
||||||
|
serverId: string
|
||||||
|
): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
tools?: Array<{
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
enabled: boolean;
|
||||||
|
}>;
|
||||||
|
error?: string;
|
||||||
|
}> => this.post('/api/mcp/tools', { serverId }),
|
||||||
|
};
|
||||||
|
|
||||||
// Pipeline API - custom workflow pipeline steps
|
// Pipeline API - custom workflow pipeline steps
|
||||||
pipeline = {
|
pipeline = {
|
||||||
getConfig: (
|
getConfig: (
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
AgentModel,
|
AgentModel,
|
||||||
PlanningMode,
|
PlanningMode,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
|
MCPServerConfig,
|
||||||
FeatureStatusWithPipeline,
|
FeatureStatusWithPipeline,
|
||||||
PipelineConfig,
|
PipelineConfig,
|
||||||
PipelineStep,
|
PipelineStep,
|
||||||
@@ -486,6 +487,11 @@ export interface AppState {
|
|||||||
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
autoLoadClaudeMd: boolean; // Auto-load CLAUDE.md files using SDK's settingSources option
|
||||||
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
|
enableSandboxMode: boolean; // Enable sandbox mode for bash commands (may cause issues on some systems)
|
||||||
|
|
||||||
|
// MCP Servers
|
||||||
|
mcpServers: MCPServerConfig[]; // List of configured MCP servers for agent use
|
||||||
|
mcpAutoApproveTools: boolean; // Auto-approve MCP tool calls without permission prompts
|
||||||
|
mcpUnrestrictedTools: boolean; // Allow unrestricted tools when MCP servers are enabled
|
||||||
|
|
||||||
// Project Analysis
|
// Project Analysis
|
||||||
projectAnalysis: ProjectAnalysis | null;
|
projectAnalysis: ProjectAnalysis | null;
|
||||||
isAnalyzing: boolean;
|
isAnalyzing: boolean;
|
||||||
@@ -765,6 +771,8 @@ export interface AppActions {
|
|||||||
// Claude Agent SDK Settings actions
|
// Claude Agent SDK Settings actions
|
||||||
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
setAutoLoadClaudeMd: (enabled: boolean) => Promise<void>;
|
||||||
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
setEnableSandboxMode: (enabled: boolean) => Promise<void>;
|
||||||
|
setMcpAutoApproveTools: (enabled: boolean) => Promise<void>;
|
||||||
|
setMcpUnrestrictedTools: (enabled: boolean) => Promise<void>;
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
addAIProfile: (profile: Omit<AIProfile, 'id'>) => void;
|
||||||
@@ -773,6 +781,12 @@ export interface AppActions {
|
|||||||
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
reorderAIProfiles: (oldIndex: number, newIndex: number) => void;
|
||||||
resetAIProfiles: () => void;
|
resetAIProfiles: () => void;
|
||||||
|
|
||||||
|
// MCP Server actions
|
||||||
|
addMCPServer: (server: Omit<MCPServerConfig, 'id'>) => void;
|
||||||
|
updateMCPServer: (id: string, updates: Partial<MCPServerConfig>) => void;
|
||||||
|
removeMCPServer: (id: string) => void;
|
||||||
|
reorderMCPServers: (oldIndex: number, newIndex: number) => void;
|
||||||
|
|
||||||
// Project Analysis actions
|
// Project Analysis actions
|
||||||
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
setProjectAnalysis: (analysis: ProjectAnalysis | null) => void;
|
||||||
setIsAnalyzing: (analyzing: boolean) => void;
|
setIsAnalyzing: (analyzing: boolean) => void;
|
||||||
@@ -955,6 +969,9 @@ const initialState: AppState = {
|
|||||||
validationModel: 'opus', // Default to opus for GitHub issue validation
|
validationModel: 'opus', // Default to opus for GitHub issue validation
|
||||||
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
autoLoadClaudeMd: false, // Default to disabled (user must opt-in)
|
||||||
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur)
|
enableSandboxMode: true, // Default to enabled for security (can be disabled if issues occur)
|
||||||
|
mcpServers: [], // No MCP servers configured by default
|
||||||
|
mcpAutoApproveTools: true, // Default to enabled - bypass permission prompts for MCP tools
|
||||||
|
mcpUnrestrictedTools: true, // Default to enabled - don't filter allowedTools when MCP enabled
|
||||||
aiProfiles: DEFAULT_AI_PROFILES,
|
aiProfiles: DEFAULT_AI_PROFILES,
|
||||||
projectAnalysis: null,
|
projectAnalysis: null,
|
||||||
isAnalyzing: false,
|
isAnalyzing: false,
|
||||||
@@ -1598,6 +1615,18 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
await syncSettingsToServer();
|
await syncSettingsToServer();
|
||||||
},
|
},
|
||||||
|
setMcpAutoApproveTools: async (enabled) => {
|
||||||
|
set({ mcpAutoApproveTools: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
await syncSettingsToServer();
|
||||||
|
},
|
||||||
|
setMcpUnrestrictedTools: async (enabled) => {
|
||||||
|
set({ mcpUnrestrictedTools: enabled });
|
||||||
|
// Sync to server settings file
|
||||||
|
const { syncSettingsToServer } = await import('@/hooks/use-settings-migration');
|
||||||
|
await syncSettingsToServer();
|
||||||
|
},
|
||||||
|
|
||||||
// AI Profile actions
|
// AI Profile actions
|
||||||
addAIProfile: (profile) => {
|
addAIProfile: (profile) => {
|
||||||
@@ -1639,6 +1668,29 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
|
set({ aiProfiles: [...DEFAULT_AI_PROFILES, ...userProfiles] });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// MCP Server actions
|
||||||
|
addMCPServer: (server) => {
|
||||||
|
const id = `mcp-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
set({ mcpServers: [...get().mcpServers, { ...server, id, enabled: true }] });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateMCPServer: (id, updates) => {
|
||||||
|
set({
|
||||||
|
mcpServers: get().mcpServers.map((s) => (s.id === id ? { ...s, ...updates } : s)),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeMCPServer: (id) => {
|
||||||
|
set({ mcpServers: get().mcpServers.filter((s) => s.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
reorderMCPServers: (oldIndex, newIndex) => {
|
||||||
|
const servers = [...get().mcpServers];
|
||||||
|
const [movedServer] = servers.splice(oldIndex, 1);
|
||||||
|
servers.splice(newIndex, 0, movedServer);
|
||||||
|
set({ mcpServers: servers });
|
||||||
|
},
|
||||||
|
|
||||||
// Project Analysis actions
|
// Project Analysis actions
|
||||||
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
setProjectAnalysis: (analysis) => set({ projectAnalysis: analysis }),
|
||||||
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
setIsAnalyzing: (analyzing) => set({ isAnalyzing: analyzing }),
|
||||||
@@ -2853,6 +2905,10 @@ export const useAppStore = create<AppState & AppActions>()(
|
|||||||
validationModel: state.validationModel,
|
validationModel: state.validationModel,
|
||||||
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
autoLoadClaudeMd: state.autoLoadClaudeMd,
|
||||||
enableSandboxMode: state.enableSandboxMode,
|
enableSandboxMode: state.enableSandboxMode,
|
||||||
|
// MCP settings
|
||||||
|
mcpServers: state.mcpServers,
|
||||||
|
mcpAutoApproveTools: state.mcpAutoApproveTools,
|
||||||
|
mcpUnrestrictedTools: state.mcpUnrestrictedTools,
|
||||||
// Profiles and sessions
|
// Profiles and sessions
|
||||||
aiProfiles: state.aiProfiles,
|
aiProfiles: state.aiProfiles,
|
||||||
chatSessions: state.chatSessions,
|
chatSessions: state.chatSessions,
|
||||||
|
|||||||
@@ -13,6 +13,10 @@ export type {
|
|||||||
InstallationStatus,
|
InstallationStatus,
|
||||||
ValidationResult,
|
ValidationResult,
|
||||||
ModelDefinition,
|
ModelDefinition,
|
||||||
|
McpServerConfig,
|
||||||
|
McpStdioServerConfig,
|
||||||
|
McpSSEServerConfig,
|
||||||
|
McpHttpServerConfig,
|
||||||
} from './provider.js';
|
} from './provider.js';
|
||||||
|
|
||||||
// Feature types
|
// Feature types
|
||||||
@@ -54,6 +58,8 @@ export type {
|
|||||||
ModelProvider,
|
ModelProvider,
|
||||||
KeyboardShortcuts,
|
KeyboardShortcuts,
|
||||||
AIProfile,
|
AIProfile,
|
||||||
|
MCPToolInfo,
|
||||||
|
MCPServerConfig,
|
||||||
ProjectRef,
|
ProjectRef,
|
||||||
TrashedProjectRef,
|
TrashedProjectRef,
|
||||||
ChatSessionRef,
|
ChatSessionRef,
|
||||||
|
|||||||
@@ -28,6 +28,38 @@ export interface SystemPromptPreset {
|
|||||||
append?: string;
|
append?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP server configuration types for SDK options
|
||||||
|
* Matches the Claude Agent SDK's McpServerConfig types
|
||||||
|
*/
|
||||||
|
export type McpServerConfig = McpStdioServerConfig | McpSSEServerConfig | McpHttpServerConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stdio-based MCP server (subprocess)
|
||||||
|
* Note: `type` is optional and defaults to 'stdio' to match SDK behavior
|
||||||
|
* and allow simpler configs like { command: "node", args: ["server.js"] }
|
||||||
|
*/
|
||||||
|
export interface McpStdioServerConfig {
|
||||||
|
type?: 'stdio';
|
||||||
|
command: string;
|
||||||
|
args?: string[];
|
||||||
|
env?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SSE-based MCP server */
|
||||||
|
export interface McpSSEServerConfig {
|
||||||
|
type: 'sse';
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HTTP-based MCP server */
|
||||||
|
export interface McpHttpServerConfig {
|
||||||
|
type: 'http';
|
||||||
|
url: string;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Options for executing a query via a provider
|
* Options for executing a query via a provider
|
||||||
*/
|
*/
|
||||||
@@ -38,7 +70,9 @@ export interface ExecuteOptions {
|
|||||||
systemPrompt?: string | SystemPromptPreset;
|
systemPrompt?: string | SystemPromptPreset;
|
||||||
maxTurns?: number;
|
maxTurns?: number;
|
||||||
allowedTools?: string[];
|
allowedTools?: string[];
|
||||||
mcpServers?: Record<string, unknown>;
|
mcpServers?: Record<string, McpServerConfig>;
|
||||||
|
mcpAutoApproveTools?: boolean; // Auto-approve MCP tool calls without permission prompts
|
||||||
|
mcpUnrestrictedTools?: boolean; // Allow unrestricted tools when MCP servers are enabled
|
||||||
abortController?: AbortController;
|
abortController?: AbortController;
|
||||||
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
conversationHistory?: ConversationMessage[]; // Previous messages for context
|
||||||
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
sdkSessionId?: string; // Claude SDK session ID for resuming conversations
|
||||||
|
|||||||
@@ -163,6 +163,55 @@ export interface AIProfile {
|
|||||||
icon?: string;
|
icon?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCPToolInfo - Information about a tool provided by an MCP server
|
||||||
|
*
|
||||||
|
* Contains the tool's name, description, and whether it's enabled for use.
|
||||||
|
*/
|
||||||
|
export interface MCPToolInfo {
|
||||||
|
/** Tool name as exposed by the MCP server */
|
||||||
|
name: string;
|
||||||
|
/** Description of what the tool does */
|
||||||
|
description?: string;
|
||||||
|
/** JSON Schema for the tool's input parameters */
|
||||||
|
inputSchema?: Record<string, unknown>;
|
||||||
|
/** Whether this tool is enabled for use (defaults to true) */
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCPServerConfig - Configuration for an MCP (Model Context Protocol) server
|
||||||
|
*
|
||||||
|
* MCP servers provide additional tools and capabilities to AI agents.
|
||||||
|
* Supports stdio (subprocess), SSE, and HTTP transport types.
|
||||||
|
*/
|
||||||
|
export interface MCPServerConfig {
|
||||||
|
/** Unique identifier for the server config */
|
||||||
|
id: string;
|
||||||
|
/** Display name for the server */
|
||||||
|
name: string;
|
||||||
|
/** User-friendly description of what this server provides */
|
||||||
|
description?: string;
|
||||||
|
/** Transport type: stdio (default), sse, or http */
|
||||||
|
type?: 'stdio' | 'sse' | 'http';
|
||||||
|
/** For stdio: command to execute (e.g., 'node', 'python', 'npx') */
|
||||||
|
command?: string;
|
||||||
|
/** For stdio: arguments to pass to the command */
|
||||||
|
args?: string[];
|
||||||
|
/** For stdio: environment variables to set */
|
||||||
|
env?: Record<string, string>;
|
||||||
|
/** For sse/http: URL endpoint */
|
||||||
|
url?: string;
|
||||||
|
/** For sse/http: headers to include in requests */
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
/** Whether this server is enabled */
|
||||||
|
enabled?: boolean;
|
||||||
|
/** Tools discovered from this server with their enabled states */
|
||||||
|
tools?: MCPToolInfo[];
|
||||||
|
/** Timestamp when tools were last fetched */
|
||||||
|
toolsLastFetched?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ProjectRef - Minimal reference to a project stored in global settings
|
* ProjectRef - Minimal reference to a project stored in global settings
|
||||||
*
|
*
|
||||||
@@ -303,6 +352,14 @@ export interface GlobalSettings {
|
|||||||
autoLoadClaudeMd?: boolean;
|
autoLoadClaudeMd?: boolean;
|
||||||
/** Enable sandbox mode for bash commands (default: true, disable if issues occur) */
|
/** Enable sandbox mode for bash commands (default: true, disable if issues occur) */
|
||||||
enableSandboxMode?: boolean;
|
enableSandboxMode?: boolean;
|
||||||
|
|
||||||
|
// MCP Server Configuration
|
||||||
|
/** List of configured MCP servers for agent use */
|
||||||
|
mcpServers: MCPServerConfig[];
|
||||||
|
/** Auto-approve MCP tool calls without permission prompts (uses bypassPermissions mode) */
|
||||||
|
mcpAutoApproveTools?: boolean;
|
||||||
|
/** Allow unrestricted tools when MCP servers are enabled (don't filter allowedTools) */
|
||||||
|
mcpUnrestrictedTools?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -462,6 +519,11 @@ export const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
|
|||||||
lastSelectedSessionByProject: {},
|
lastSelectedSessionByProject: {},
|
||||||
autoLoadClaudeMd: false,
|
autoLoadClaudeMd: false,
|
||||||
enableSandboxMode: true,
|
enableSandboxMode: true,
|
||||||
|
mcpServers: [],
|
||||||
|
// Default to true for autonomous workflow. Security is enforced when adding servers
|
||||||
|
// via the security warning dialog that explains the risks.
|
||||||
|
mcpAutoApproveTools: true,
|
||||||
|
mcpUnrestrictedTools: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default credentials (empty strings - user must provide API keys) */
|
/** Default credentials (empty strings - user must provide API keys) */
|
||||||
|
|||||||
219
package-lock.json
generated
219
package-lock.json
generated
@@ -36,6 +36,7 @@
|
|||||||
"@automaker/prompts": "^1.0.0",
|
"@automaker/prompts": "^1.0.0",
|
||||||
"@automaker/types": "^1.0.0",
|
"@automaker/types": "^1.0.0",
|
||||||
"@automaker/utils": "^1.0.0",
|
"@automaker/utils": "^1.0.0",
|
||||||
|
"@modelcontextprotocol/sdk": "^1.25.1",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -2647,6 +2648,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@hono/node-server": {
|
||||||
|
"version": "1.19.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.7.tgz",
|
||||||
|
"integrity": "sha512-vUcD0uauS7EU2caukW8z5lJKtoGMokxNbJtBiwHgpqxEXokaHCBkQUmCHhjFB1VUTWdqj25QoMkMKzgjq+uhrw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.14.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"hono": "^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -3473,6 +3486,67 @@
|
|||||||
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk": {
|
||||||
|
"version": "1.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.25.1.tgz",
|
||||||
|
"integrity": "sha512-yO28oVFFC7EBoiKdAn+VqRm+plcfv4v0xp6osG/VsCB0NlPZWi87ajbCZZ8f/RvOFLEu7//rSRmuZZ7lMoe3gQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@hono/node-server": "^1.19.7",
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"ajv-formats": "^3.0.1",
|
||||||
|
"content-type": "^1.0.5",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"cross-spawn": "^7.0.5",
|
||||||
|
"eventsource": "^3.0.2",
|
||||||
|
"eventsource-parser": "^3.0.0",
|
||||||
|
"express": "^5.0.1",
|
||||||
|
"express-rate-limit": "^7.5.0",
|
||||||
|
"jose": "^6.1.1",
|
||||||
|
"json-schema-typed": "^8.0.2",
|
||||||
|
"pkce-challenge": "^5.0.0",
|
||||||
|
"raw-body": "^3.0.0",
|
||||||
|
"zod": "^3.25 || ^4.0",
|
||||||
|
"zod-to-json-schema": "^3.25.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@cfworker/json-schema": "^4.1.1",
|
||||||
|
"zod": "^3.25 || ^4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@cfworker/json-schema": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"zod": {
|
||||||
|
"optional": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@modelcontextprotocol/sdk/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@next/env": {
|
"node_modules/@next/env": {
|
||||||
"version": "16.0.10",
|
"version": "16.0.10",
|
||||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.0.10.tgz",
|
||||||
@@ -6802,6 +6876,45 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv-formats": {
|
||||||
|
"version": "3.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
|
||||||
|
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"ajv": "^8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ajv": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/ajv": {
|
||||||
|
"version": "8.17.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
|
||||||
|
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ajv-formats/node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ajv-keywords": {
|
"node_modules/ajv-keywords": {
|
||||||
"version": "3.5.2",
|
"version": "3.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz",
|
||||||
@@ -9436,6 +9549,27 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/eventsource": {
|
||||||
|
"version": "3.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz",
|
||||||
|
"integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"eventsource-parser": "^3.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/eventsource-parser": {
|
||||||
|
"version": "3.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz",
|
||||||
|
"integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/expect-type": {
|
"node_modules/expect-type": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||||
@@ -9458,6 +9592,7 @@
|
|||||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||||
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"accepts": "^2.0.0",
|
"accepts": "^2.0.0",
|
||||||
"body-parser": "^2.2.1",
|
"body-parser": "^2.2.1",
|
||||||
@@ -9496,6 +9631,21 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-rate-limit": {
|
||||||
|
"version": "7.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
|
"integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/express-rate-limit"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"express": ">= 4.11"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -9538,7 +9688,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -9555,6 +9704,22 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
@@ -10320,6 +10485,16 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"url": "https://opencollective.com/unified"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hono": {
|
||||||
|
"version": "4.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/hono/-/hono-4.11.3.tgz",
|
||||||
|
"integrity": "sha512-PmQi306+M/ct/m5s66Hrg+adPnkD5jiO6IjA7WhWw0gSBSo1EcRegwuI1deZ+wd5pzCGynCcn2DprnE4/yEV4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
@@ -10911,6 +11086,15 @@
|
|||||||
"jiti": "lib/jiti-cli.mjs"
|
"jiti": "lib/jiti-cli.mjs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jose": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/panva"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/js-tokens": {
|
"node_modules/js-tokens": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||||
@@ -10958,6 +11142,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-typed": {
|
||||||
|
"version": "8.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz",
|
||||||
|
"integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/json-stable-stringify-without-jsonify": {
|
"node_modules/json-stable-stringify-without-jsonify": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz",
|
||||||
@@ -13468,6 +13658,15 @@
|
|||||||
"node": ">=0.10"
|
"node": ">=0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/pkce-challenge": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16.20.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/playwright": {
|
"node_modules/playwright": {
|
||||||
"version": "1.57.0",
|
"version": "1.57.0",
|
||||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
|
||||||
@@ -14034,6 +14233,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resedit": {
|
"node_modules/resedit": {
|
||||||
"version": "1.7.2",
|
"version": "1.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/resedit/-/resedit-1.7.2.tgz",
|
||||||
@@ -16549,6 +16757,15 @@
|
|||||||
"url": "https://github.com/sponsors/colinhacks"
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/zod-to-json-schema": {
|
||||||
|
"version": "3.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz",
|
||||||
|
"integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"zod": "^3.25 || ^4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/zustand": {
|
"node_modules/zustand": {
|
||||||
"version": "5.0.9",
|
"version": "5.0.9",
|
||||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||||
|
|||||||
Reference in New Issue
Block a user