@@ -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
199241func 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