fix(import): reject servers that fail config validation before persisting (RC4-IMPORT-001) by Dumbris · Pull Request #1490 · smart-mcp-proxy/mcpproxy-go · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions internal/config/config.go
19 changes: 19 additions & 0 deletions internal/configimport/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ package configimport

import (
"fmt"
"strings"
"time"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

// Import parses configuration content and imports servers.
Expand Down Expand Up @@ -146,6 +149,22 @@ func Import(content []byte, opts *ImportOptions) (*ImportResult, error) {
// Map to ServerConfig
serverConfig, skipped, warnings := MapToServerConfig(parsed, opts.Now)

// Reject entries the next config.Load would refuse (e.g. a URL-only
// entry forced through a stdio-only format yields a commandless stdio
// server). Persisting one bricks the next startup with exit code 4.
if verrs := config.ValidateServerForLoad(serverConfig); len(verrs) > 0 {
msgs := make([]string, 0, len(verrs))
for _, ve := range verrs {
msgs = append(msgs, ve.Message)
}
result.Failed = append(result.Failed, FailedServer{
Name: originalName,
Error: "invalid_server",
Details: strings.Join(msgs, "; "),
})
continue
}

// Override quarantine if SkipQuarantine is set
if opts.SkipQuarantine {
serverConfig.Quarantined = false
Expand Down
34 changes: 34 additions & 0 deletions internal/configimport/import_invalid_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package configimport

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// RC4-IMPORT-001: a URL-only entry forced through the claude-desktop parser
// maps to a commandless stdio server. config.Load rejects that on the next
// boot (exit 4), so Import must report it as failed and never offer it for
// persistence, while valid entries in the same payload still import.
func TestImport_RejectsServerThatFailsConfigValidation(t *testing.T) {
content := []byte(`{"mcpServers": {
"broken": {"url": "https://example.com/mcp"},
"good": {"command": "npx", "args": ["-y", "some-server"]}
}}`)

for _, skipQuarantine := range []bool{false, true} {
result, err := Import(content, &ImportOptions{FormatHint: FormatClaudeDesktop, SkipQuarantine: skipQuarantine})
require.NoError(t, err)

require.Len(t, result.Imported, 1, "only the valid server may be importable")
assert.Equal(t, "good", result.Imported[0].Server.Name)

require.Len(t, result.Failed, 1)
assert.Equal(t, "broken", result.Failed[0].Name)
assert.Equal(t, "invalid_server", result.Failed[0].Error)
assert.Contains(t, result.Failed[0].Details, "command is required")
assert.Equal(t, 1, result.Summary.Failed)
assert.Equal(t, 1, result.Summary.Imported)
}
}
10 changes: 8 additions & 2 deletions internal/configimport/import_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,14 @@ func TestImport(t *testing.T) {
if result.Format != FormatCodex {
t.Errorf("Format = %v, want %v", result.Format, FormatCodex)
}
if result.Summary.Imported != 5 {
t.Errorf("Summary.Imported = %d, want 5", result.Summary.Imported)
// The fixture's `filesystem` entry has cwd=/home/user, which fails the
// same working_dir-exists check config.Load applies when that directory
// is absent on the machine running the test, so it may be Failed.
if got := result.Summary.Imported + result.Summary.Failed; got != 5 {
t.Errorf("Summary.Imported+Failed = %d, want 5", got)
}
if result.Summary.Imported < 4 {
t.Errorf("Summary.Imported = %d, want at least 4", result.Summary.Imported)
}
})

Expand Down
23 changes: 23 additions & 0 deletions internal/httpapi/import.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"strings"
"time"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/configimport"
"github.com/smart-mcp-proxy/mcpproxy-go/internal/oauth"
)
Expand Down Expand Up @@ -507,6 +508,28 @@ func (s *Server) runImport(r *http.Request, content []byte, formatHint string, s
result.Imported[i].Server.Name = newName
}
}
// configimport validated the pre-rename name; re-check the renamed
// servers with the same boot-path validation so a rename cannot
// persist an entry the next startup refuses (RC4-IMPORT-001).
kept := result.Imported[:0]
for _, imported := range result.Imported {
if errs := config.ValidateServerForLoad(imported.Server); len(errs) > 0 {
msgs := make([]string, 0, len(errs))
for _, e := range errs {
msgs = append(msgs, e.Error())
}
result.Failed = append(result.Failed, configimport.FailedServer{
Name: imported.Server.Name,
Error: "invalid_server",
Details: strings.Join(msgs, "; "),
})
continue
}
kept = append(kept, imported)
}
result.Imported = kept
result.Summary.Imported = len(result.Imported)
result.Summary.Failed = len(result.Failed)
}

// Apply the caller's field overrides (Paste tab env/header edits)
Expand Down
65 changes: 65 additions & 0 deletions internal/httpapi/import_invalid_server_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package httpapi

import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"sync"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/smart-mcp-proxy/mcpproxy-go/internal/config"
)

type recordingImportController struct {
mockImportController
mu sync.Mutex
added []string
}

func (m *recordingImportController) AddServer(_ context.Context, s *config.ServerConfig) error {
m.mu.Lock()
defer m.mu.Unlock()
m.added = append(m.added, s.Name)
return nil
}

// RC4-IMPORT-001: a URL-only entry imported with a forced claude-desktop
// format and skip_quarantine=true used to be persisted as a commandless stdio
// server, which made the next core startup exit with code 4.
func TestImportServersJSON_RejectsInvalidServerNotPersisted(t *testing.T) {
mock := &recordingImportController{mockImportController: mockImportController{apiKey: "test-key"}}
server := NewServer(mock, zap.NewNop().Sugar(), nil)

body, _ := json.Marshal(ImportRequest{
Format: "claude-desktop",
Content: `{"mcpServers": {
"broken": {"url": "https://example.com/mcp"},
"good": {"command": "npx", "args": ["-y", "some-server"]}
}}`,
})
req := httptest.NewRequest("POST", "/api/v1/servers/import/json?skip_quarantine=true", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", "test-key")
rr := httptest.NewRecorder()
server.router.ServeHTTP(rr, req)

require.Equal(t, http.StatusOK, rr.Code, rr.Body.String())
var wrapped wrappedImportResponse
require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &wrapped))

require.Len(t, wrapped.Data.Imported, 1)
assert.Equal(t, "good", wrapped.Data.Imported[0].Name)
require.Len(t, wrapped.Data.Failed, 1)
assert.Equal(t, "broken", wrapped.Data.Failed[0].Name)
assert.Contains(t, wrapped.Data.Failed[0].Details, "command is required")

mock.mu.Lock()
defer mock.mu.Unlock()
assert.Equal(t, []string{"good"}, mock.added, "the invalid server must never reach AddServer")
}
22 changes: 22 additions & 0 deletions internal/httpapi/import_test.go
Loading