Skip to content

Commit dbd1cd2

Browse files
Dumbrisclaude
andcommitted
feat: add config import from Claude Desktop, Claude Code, Cursor, Codex, Gemini
Adds the ability to import MCP server configurations from various IDE/CLI tools: - Claude Desktop (JSON with mcpServers object) - Claude Code (JSON with mcpServers array) - Cursor IDE (JSON with mcpServers object, supports env arrays) - Codex CLI (TOML with [mcp_servers.name] sections) - Gemini CLI (JSON with mcpServers map with url keys) Features: - Auto-detect configuration format from content structure - REST API endpoints: POST /api/v1/servers/import (file upload) and POST /api/v1/servers/import/json (paste content) - Preview mode to review servers before importing - Selective import by server name filter - Web UI with file upload and paste content tabs - CLI command: mcpproxy upstream import <file> - Client-side JSON validation with helpful error messages - Duplicate server detection and skip handling The import feature normalizes different config formats to MCPProxy's internal server model, handling protocol detection, env var formats, and field mappings. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent af2831c commit dbd1cd2

40 files changed

Lines changed: 5069 additions & 192 deletions

cmd/mcpproxy/upstream_cmd.go

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cli/output"
2121
"github.com/smart-mcp-proxy/mcpproxy-go/internal/cliclient"
2222
"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
23+
"github.com/smart-mcp-proxy/mcpproxy-go/internal/configimport"
2324
"github.com/smart-mcp-proxy/mcpproxy-go/internal/health"
2425
"github.com/smart-mcp-proxy/mcpproxy-go/internal/logs"
2526
"github.com/smart-mcp-proxy/mcpproxy-go/internal/reqcontext"
@@ -120,6 +121,36 @@ Examples:
120121
RunE: runUpstreamAddJSON,
121122
}
122123

124+
upstreamImportCmd = &cobra.Command{
125+
Use: "import <path>",
126+
Short: "Import servers from external configuration file",
127+
Long: `Import MCP server configurations from Claude Desktop, Claude Code, Cursor IDE, Codex CLI, or Gemini CLI.
128+
129+
Supported formats:
130+
- Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json
131+
- Claude Code: .claude/settings.json or .claude.json
132+
- Cursor IDE: ~/.cursor/mcp.json
133+
- Codex CLI: ~/.codex/config.toml
134+
- Gemini CLI: ~/.gemini/settings.json
135+
136+
The format is auto-detected from file content. Imported servers are quarantined by default.
137+
138+
Examples:
139+
# Import all servers from Claude Desktop config
140+
mcpproxy upstream import ~/Library/Application\ Support/Claude/claude_desktop_config.json
141+
142+
# Preview import without making changes
143+
mcpproxy upstream import --dry-run ~/Library/Application\ Support/Claude/claude_desktop_config.json
144+
145+
# Import specific server by name
146+
mcpproxy upstream import --server github ~/.cursor/mcp.json
147+
148+
# Force format detection
149+
mcpproxy upstream import --format claude-desktop config.json`,
150+
Args: cobra.ExactArgs(1),
151+
RunE: runUpstreamImport,
152+
}
153+
123154
// Command flags
124155
upstreamLogLevel string
125156
upstreamConfigPath string
@@ -140,6 +171,11 @@ Examples:
140171
// Remove command flags
141172
upstreamRemoveYes bool
142173
upstreamRemoveIfExists bool
174+
175+
// Import command flags
176+
upstreamImportServer string
177+
upstreamImportFormat string
178+
upstreamImportDryRun bool
143179
)
144180

145181
// GetUpstreamCommand returns the upstream command for adding to the root command.
@@ -159,6 +195,7 @@ func init() {
159195
upstreamCmd.AddCommand(upstreamAddCmd)
160196
upstreamCmd.AddCommand(upstreamRemoveCmd)
161197
upstreamCmd.AddCommand(upstreamAddJSONCmd)
198+
upstreamCmd.AddCommand(upstreamImportCmd)
162199

163200
// Define flags (note: output format handled by global --output/-o flag from root command)
164201
upstreamListCmd.Flags().StringVarP(&upstreamLogLevel, "log-level", "l", "warn", "Log level (trace, debug, info, warn, error)")
@@ -194,6 +231,11 @@ func init() {
194231
upstreamRemoveCmd.Flags().BoolVar(&upstreamRemoveYes, "yes", false, "Skip confirmation prompt")
195232
upstreamRemoveCmd.Flags().BoolVarP(&upstreamRemoveYes, "y", "y", false, "Skip confirmation prompt (short form)")
196233
upstreamRemoveCmd.Flags().BoolVar(&upstreamRemoveIfExists, "if-exists", false, "Don't error if server doesn't exist")
234+
235+
// Import command flags
236+
upstreamImportCmd.Flags().StringVarP(&upstreamImportServer, "server", "s", "", "Import only a specific server by name")
237+
upstreamImportCmd.Flags().StringVar(&upstreamImportFormat, "format", "", "Force format (claude-desktop, claude-code, cursor, codex, gemini)")
238+
upstreamImportCmd.Flags().BoolVar(&upstreamImportDryRun, "dry-run", false, "Preview import without making changes")
197239
}
198240

199241
func runUpstreamList(_ *cobra.Command, _ []string) error {
@@ -1289,3 +1331,294 @@ func promptConfirmation(message string) (bool, error) {
12891331
response = strings.ToLower(strings.TrimSpace(response))
12901332
return response == "y" || response == "yes", nil
12911333
}
1334+
1335+
// runUpstreamImport handles the 'upstream import' command
1336+
func runUpstreamImport(_ *cobra.Command, args []string) error {
1337+
filePath := args[0]
1338+
1339+
// Read file content
1340+
content, err := os.ReadFile(filePath)
1341+
if err != nil {
1342+
return outputError(output.NewStructuredError(output.ErrCodeInvalidInput, fmt.Sprintf("failed to read file: %v", err)).
1343+
WithGuidance("Check that the file exists and is readable"), output.ErrCodeInvalidInput)
1344+
}
1345+
1346+
// Build import options
1347+
opts := &configimport.ImportOptions{
1348+
Preview: upstreamImportDryRun,
1349+
Now: time.Now(),
1350+
}
1351+
1352+
// Parse format hint if provided
1353+
if upstreamImportFormat != "" {
1354+
format := parseImportFormat(upstreamImportFormat)
1355+
if format == configimport.FormatUnknown {
1356+
return outputError(output.NewStructuredError(output.ErrCodeInvalidInput, fmt.Sprintf("unknown format: %s", upstreamImportFormat)).
1357+
WithGuidance("Valid formats: claude-desktop, claude-code, cursor, codex, gemini"), output.ErrCodeInvalidInput)
1358+
}
1359+
opts.FormatHint = format
1360+
}
1361+
1362+
// Filter by server name if specified
1363+
if upstreamImportServer != "" {
1364+
opts.ServerNames = []string{upstreamImportServer}
1365+
}
1366+
1367+
// Load current configuration to check for existing servers
1368+
globalConfig, err := loadUpstreamConfig()
1369+
if err != nil {
1370+
return outputError(output.NewStructuredError(output.ErrCodeConfigNotFound, err.Error()).
1371+
WithGuidance("Check that your config file exists and is valid").
1372+
WithRecoveryCommand("mcpproxy doctor"), output.ErrCodeConfigNotFound)
1373+
}
1374+
1375+
// Build list of existing server names
1376+
existingNames := make([]string, len(globalConfig.Servers))
1377+
for i, srv := range globalConfig.Servers {
1378+
existingNames[i] = srv.Name
1379+
}
1380+
opts.ExistingServers = existingNames
1381+
1382+
// Run import
1383+
result, err := configimport.Import(content, opts)
1384+
if err != nil {
1385+
// Check if it's a format detection error
1386+
var importErr *configimport.ImportError
1387+
if errors.As(err, &importErr) {
1388+
return outputError(output.NewStructuredError(output.ErrCodeInvalidInput, importErr.Message).
1389+
WithGuidance("Use --format to specify the configuration format"), output.ErrCodeInvalidInput)
1390+
}
1391+
return outputError(output.NewStructuredError(output.ErrCodeOperationFailed, err.Error()), output.ErrCodeOperationFailed)
1392+
}
1393+
1394+
// Output results based on format
1395+
outputFormat := ResolveOutputFormat()
1396+
1397+
if outputFormat == "json" || outputFormat == "yaml" {
1398+
return outputImportResultStructured(result, outputFormat)
1399+
}
1400+
1401+
return outputImportResultTable(result, upstreamImportDryRun, globalConfig)
1402+
}
1403+
1404+
// parseImportFormat converts a format string to ConfigFormat
1405+
func parseImportFormat(format string) configimport.ConfigFormat {
1406+
switch strings.ToLower(format) {
1407+
case "claude-desktop", "claudedesktop":
1408+
return configimport.FormatClaudeDesktop
1409+
case "claude-code", "claudecode":
1410+
return configimport.FormatClaudeCode
1411+
case "cursor":
1412+
return configimport.FormatCursor
1413+
case "codex":
1414+
return configimport.FormatCodex
1415+
case "gemini":
1416+
return configimport.FormatGemini
1417+
default:
1418+
return configimport.FormatUnknown
1419+
}
1420+
}
1421+
1422+
// outputImportResultStructured outputs the import result in JSON/YAML format
1423+
func outputImportResultStructured(result *configimport.ImportResult, format string) error {
1424+
// Build output structure
1425+
output := map[string]interface{}{
1426+
"format": result.Format,
1427+
"format_name": result.FormatDisplayName,
1428+
"summary": result.Summary,
1429+
"imported": buildImportedServersOutput(result.Imported),
1430+
"skipped": result.Skipped,
1431+
"failed": result.Failed,
1432+
"warnings": result.Warnings,
1433+
}
1434+
1435+
formatter, err := GetOutputFormatter()
1436+
if err != nil {
1437+
return err
1438+
}
1439+
1440+
formatted, err := formatter.Format(output)
1441+
if err != nil {
1442+
return fmt.Errorf("failed to format output: %w", err)
1443+
}
1444+
1445+
fmt.Println(formatted)
1446+
return nil
1447+
}
1448+
1449+
// buildImportedServersOutput builds the output structure for imported servers
1450+
func buildImportedServersOutput(imported []*configimport.ImportedServer) []map[string]interface{} {
1451+
result := make([]map[string]interface{}, len(imported))
1452+
for i, s := range imported {
1453+
result[i] = map[string]interface{}{
1454+
"name": s.Server.Name,
1455+
"protocol": s.Server.Protocol,
1456+
"url": s.Server.URL,
1457+
"command": s.Server.Command,
1458+
"args": s.Server.Args,
1459+
"source_format": s.SourceFormat,
1460+
"original_name": s.OriginalName,
1461+
"fields_skipped": s.FieldsSkipped,
1462+
"warnings": s.Warnings,
1463+
}
1464+
}
1465+
return result
1466+
}
1467+
1468+
// outputImportResultTable outputs the import result in table format
1469+
func outputImportResultTable(result *configimport.ImportResult, dryRun bool, globalConfig *config.Config) error {
1470+
// Header
1471+
if dryRun {
1472+
fmt.Println("🔍 DRY RUN - No changes will be made")
1473+
fmt.Println()
1474+
}
1475+
1476+
fmt.Printf("📁 Detected format: %s\n", result.FormatDisplayName)
1477+
fmt.Println()
1478+
1479+
// Summary
1480+
fmt.Printf("Summary:\n")
1481+
fmt.Printf(" Total servers found: %d\n", result.Summary.Total)
1482+
fmt.Printf(" To import: %d\n", result.Summary.Imported)
1483+
fmt.Printf(" Skipped: %d\n", result.Summary.Skipped)
1484+
fmt.Printf(" Failed: %d\n", result.Summary.Failed)
1485+
fmt.Println()
1486+
1487+
// Show imported servers
1488+
if len(result.Imported) > 0 {
1489+
if dryRun {
1490+
fmt.Println("Servers to import:")
1491+
} else {
1492+
fmt.Println("Imported servers:")
1493+
}
1494+
1495+
for _, s := range result.Imported {
1496+
statusIcon := "✅"
1497+
if dryRun {
1498+
statusIcon = "📋"
1499+
}
1500+
fmt.Printf(" %s %s (%s)\n", statusIcon, s.Server.Name, s.Server.Protocol)
1501+
1502+
// Show warnings for this server
1503+
if len(s.Warnings) > 0 {
1504+
for _, w := range s.Warnings {
1505+
fmt.Printf(" ⚠️ %s\n", w)
1506+
}
1507+
}
1508+
1509+
// Show skipped fields
1510+
if len(s.FieldsSkipped) > 0 {
1511+
fmt.Printf(" ℹ️ Skipped fields: %s\n", strings.Join(s.FieldsSkipped, ", "))
1512+
}
1513+
}
1514+
fmt.Println()
1515+
}
1516+
1517+
// Show skipped servers
1518+
if len(result.Skipped) > 0 {
1519+
fmt.Println("Skipped servers:")
1520+
for _, s := range result.Skipped {
1521+
reason := s.Reason
1522+
switch reason {
1523+
case "already_exists":
1524+
reason = "already exists in config"
1525+
case "filtered_out":
1526+
reason = "not in --server filter"
1527+
}
1528+
fmt.Printf(" ⏭️ %s (%s)\n", s.Name, reason)
1529+
}
1530+
fmt.Println()
1531+
}
1532+
1533+
// Show failed servers
1534+
if len(result.Failed) > 0 {
1535+
fmt.Println("Failed servers:")
1536+
for _, s := range result.Failed {
1537+
fmt.Printf(" ❌ %s: %s\n", s.Name, s.Details)
1538+
}
1539+
fmt.Println()
1540+
}
1541+
1542+
// Show global warnings
1543+
if len(result.Warnings) > 0 {
1544+
fmt.Println("Warnings:")
1545+
for _, w := range result.Warnings {
1546+
fmt.Printf(" ⚠️ %s\n", w)
1547+
}
1548+
fmt.Println()
1549+
}
1550+
1551+
// If not dry run and we have servers to import, actually add them
1552+
if !dryRun && len(result.Imported) > 0 {
1553+
err := applyImportedServers(result.Imported, globalConfig)
1554+
if err != nil {
1555+
return err
1556+
}
1557+
fmt.Println("🔒 New servers are quarantined by default. Approve them in the web UI.")
1558+
}
1559+
1560+
return nil
1561+
}
1562+
1563+
// applyImportedServers adds the imported servers to the configuration
1564+
func applyImportedServers(imported []*configimport.ImportedServer, globalConfig *config.Config) error {
1565+
// Create context
1566+
ctx := reqcontext.WithMetadata(context.Background(), reqcontext.SourceCLI)
1567+
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
1568+
defer cancel()
1569+
1570+
// Check if daemon is running
1571+
if shouldUseUpstreamDaemon(globalConfig.DataDir) {
1572+
return applyImportedServersDaemonMode(ctx, globalConfig.DataDir, imported)
1573+
}
1574+
1575+
// Direct config file mode
1576+
return applyImportedServersConfigMode(imported, globalConfig)
1577+
}
1578+
1579+
// applyImportedServersDaemonMode adds servers via the daemon
1580+
func applyImportedServersDaemonMode(ctx context.Context, dataDir string, imported []*configimport.ImportedServer) error {
1581+
socketPath := socket.DetectSocketPath(dataDir)
1582+
client := cliclient.NewClient(socketPath, nil)
1583+
1584+
for _, s := range imported {
1585+
req := &cliclient.AddServerRequest{
1586+
Name: s.Server.Name,
1587+
URL: s.Server.URL,
1588+
Command: s.Server.Command,
1589+
Args: s.Server.Args,
1590+
Env: s.Server.Env,
1591+
Headers: s.Server.Headers,
1592+
WorkingDir: s.Server.WorkingDir,
1593+
Protocol: s.Server.Protocol,
1594+
}
1595+
1596+
// All imported servers are quarantined
1597+
quarantined := true
1598+
req.Quarantined = &quarantined
1599+
1600+
_, err := client.AddServer(ctx, req)
1601+
if err != nil {
1602+
// Log error but continue with other servers
1603+
fmt.Fprintf(os.Stderr, " ❌ Failed to add '%s': %v\n", s.Server.Name, err)
1604+
}
1605+
}
1606+
1607+
return nil
1608+
}
1609+
1610+
// applyImportedServersConfigMode adds servers directly to the config file
1611+
func applyImportedServersConfigMode(imported []*configimport.ImportedServer, globalConfig *config.Config) error {
1612+
// Add all imported servers to config
1613+
for _, s := range imported {
1614+
globalConfig.Servers = append(globalConfig.Servers, s.Server)
1615+
}
1616+
1617+
// Save config
1618+
configPath := config.GetConfigPath(globalConfig.DataDir)
1619+
if err := config.SaveConfig(globalConfig, configPath); err != nil {
1620+
return fmt.Errorf("failed to save config: %w", err)
1621+
}
1622+
1623+
return nil
1624+
}

0 commit comments

Comments
 (0)