diff --git a/.env.example b/.env.example index 959a672d..7f8daf38 100644 --- a/.env.example +++ b/.env.example @@ -132,6 +132,7 @@ GATEWAY_DB_SSL_MODE=disable GATEWAY_STORAGE_API_URL= GATEWAY_STORAGE_API_CONFIG_JSON_PATH= GATEWAY_STORAGE_API_TIMEOUT=5s +GATEWAY_STORAGE_API_IGNORE_INVALID_CONFIG=false # ============================================================================= # NOTIFICATION SYSTEM diff --git a/internal/common/config/storage.go b/internal/common/config/storage.go index 9c63c661..229ced17 100644 --- a/internal/common/config/storage.go +++ b/internal/common/config/storage.go @@ -11,8 +11,9 @@ type ( } APIStorageConfig struct { - Url string `yaml:"url"` // http url for api - ConfigJSONPath string `yaml:"configJSONPath"` // configJSONPath for config in http response - Timeout time.Duration `yaml:"timeout"` // timeout for http request + Url string `yaml:"url"` // http url for api + ConfigJSONPath string `yaml:"configJSONPath"` // configJSONPath for config in http response + Timeout time.Duration `yaml:"timeout"` // timeout for http request + IgnoreInvalidConfig bool `yaml:"ignoreInvalidConfig"` // skip invalid config if true } ) diff --git a/internal/core/handler.go b/internal/core/handler.go index c0638f71..3a3157b6 100644 --- a/internal/core/handler.go +++ b/internal/core/handler.go @@ -92,7 +92,8 @@ func (h *TextHandler) Handle(resp *http.Response, tool *config.ToolConfig, tmplC return nil, fmt.Errorf("failed to render response body template: %w", err) } } - return mcp.NewCallToolResultText(rendered), nil + isError := resp.StatusCode >= 400 + return mcp.NewCallToolResultTextWithError(rendered, isError), nil } // ImageHandler is a handler for image responses diff --git a/internal/mcp/storage/api.go b/internal/mcp/storage/api.go index 244e2590..a92e00fd 100644 --- a/internal/mcp/storage/api.go +++ b/internal/mcp/storage/api.go @@ -24,23 +24,25 @@ type APIStore struct { logger *zap.Logger url string // read config from response(json body) using gjson - configJSONPath string - timeout time.Duration + configJSONPath string + ignoreInvalidConfig bool + timeout time.Duration } var _ Store = (*APIStore)(nil) // NewAPIStore creates a new api-based store -func NewAPIStore(logger *zap.Logger, url string, configJSONPath string, timeout time.Duration) (*APIStore, error) { +func NewAPIStore(logger *zap.Logger, url string, configJSONPath string, timeout time.Duration, ignoreInvalidConfig bool) (*APIStore, error) { logger = logger.Named("mcp.store") logger.Info("Using configuration url", zap.String("path", url)) return &APIStore{ - logger: logger, - url: url, - configJSONPath: configJSONPath, - timeout: timeout, + logger: logger, + url: url, + configJSONPath: configJSONPath, + timeout: timeout, + ignoreInvalidConfig: ignoreInvalidConfig, }, nil } @@ -70,10 +72,45 @@ func (s *APIStore) List(_ context.Context, _ ...bool) ([]*config.MCPConfig, erro if err != nil { return nil, err } - var configs []*config.MCPConfig - err = json.Unmarshal([]byte(jsonStr), &configs) - if err != nil { - return nil, err + + //var configs []*config.MCPConfig + //err = json.Unmarshal([]byte(jsonStr), &configs) + //if err != nil { + // return nil, err + //} + + // Unmarshal items individually (instead of the whole array) to precisely identify the failing configuration (index/name/tenant). + var items []json.RawMessage + if err := json.Unmarshal([]byte(jsonStr), &items); err != nil { + return nil, fmt.Errorf("failed to unmarshal MCP configuration array: %w", err) + } + + configs := make([]*config.MCPConfig, 0, len(items)) + for i, raw := range items { + var cfg config.MCPConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + var meta struct { + Name string `json:"name"` + Tenant string `json:"tenant"` + } + _ = json.Unmarshal(raw, &meta) + if !s.ignoreInvalidConfig { + return nil, fmt.Errorf("failed to unmarshal MCP configuration '%s' (tenant: '%s'): %w", meta.Name, meta.Tenant, err) + } + if meta.Name != "" || meta.Tenant != "" { + s.logger.Warn("failed to unmarshal MCP configuration, skipping", + zap.String("name", meta.Name), + zap.String("tenant", meta.Tenant), + zap.Int("index", i), + zap.Error(err)) + } else { + s.logger.Warn("failed to unmarshal MCP configuration, skipping", + zap.Int("index", i), + zap.Error(err)) + } + continue // Skip failed configuration + } + configs = append(configs, &cfg) } return configs, nil } diff --git a/internal/mcp/storage/api_test.go b/internal/mcp/storage/api_test.go index f4abd7ed..2ed325a3 100644 --- a/internal/mcp/storage/api_test.go +++ b/internal/mcp/storage/api_test.go @@ -7,9 +7,10 @@ import ( "testing" "time" - "github.com/amoylab/unla/internal/common/config" "github.com/stretchr/testify/assert" "go.uber.org/zap" + + "github.com/amoylab/unla/internal/common/config" ) func newTestConfig(name, tenant string) string { @@ -25,7 +26,7 @@ func TestAPIStore_Get_And_List_Basic(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "", 2*time.Second) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "", 2*time.Second, false) assert.NoError(t, err) // Get returns struct unmarshaled from response @@ -59,7 +60,7 @@ func TestAPIStore_Get_WithJSONPath(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "data.config", 2*time.Second) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "data.config", 2*time.Second, false) assert.NoError(t, err) got, err := store.Get(context.Background(), "t3", "n3") @@ -77,7 +78,7 @@ func TestAPIStore_JSONPathMissing_ReturnsError(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "data.config", 2*time.Second) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "data.config", 2*time.Second, false) assert.NoError(t, err) got, err := store.Get(context.Background(), "t", "n") @@ -92,7 +93,7 @@ func TestAPIStore_RequestTimeout(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "", 10*time.Millisecond) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "", 10*time.Millisecond, false) assert.NoError(t, err) got, err := store.Get(context.Background(), "t", "n") @@ -107,7 +108,7 @@ func TestAPIStore_ListUpdated_DelegatesToList(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "", time.Second) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "", time.Second, false) assert.NoError(t, err) lst, err := store.ListUpdated(context.Background(), time.Now().Add(-time.Hour)) @@ -126,7 +127,7 @@ func TestAPIStore_RWNoops(t *testing.T) { })) defer srv.Close() - store, err := NewAPIStore(zap.NewNop(), srv.URL, "", time.Second) + store, err := NewAPIStore(zap.NewNop(), srv.URL, "", time.Second, false) assert.NoError(t, err) // Read-only behavior diff --git a/internal/mcp/storage/factory.go b/internal/mcp/storage/factory.go index ae299142..14b3a9fe 100644 --- a/internal/mcp/storage/factory.go +++ b/internal/mcp/storage/factory.go @@ -15,7 +15,7 @@ func NewStore(logger *zap.Logger, cfg *config.StorageConfig) (Store, error) { case "db": return NewDBStore(logger, cfg) case "api": - return NewAPIStore(logger, cfg.API.Url, cfg.API.ConfigJSONPath, cfg.API.Timeout) + return NewAPIStore(logger, cfg.API.Url, cfg.API.ConfigJSONPath, cfg.API.Timeout, cfg.API.IgnoreInvalidConfig) default: return nil, fmt.Errorf("unsupported storage type: %s", cfg.Type) } diff --git a/internal/mcp/storage/factory_test.go b/internal/mcp/storage/factory_test.go index 51497a0a..be8eb4e9 100644 --- a/internal/mcp/storage/factory_test.go +++ b/internal/mcp/storage/factory_test.go @@ -4,9 +4,10 @@ import ( "testing" "time" - "github.com/amoylab/unla/internal/common/config" "github.com/stretchr/testify/assert" "go.uber.org/zap" + + "github.com/amoylab/unla/internal/common/config" ) func TestNewStore_DB_And_API_And_Unsupported(t *testing.T) { @@ -21,7 +22,7 @@ func TestNewStore_DB_And_API_And_Unsupported(t *testing.T) { assert.NotNil(t, stDB) // API store - cfgAPI := &config.StorageConfig{Type: "api", API: config.APIStorageConfig{Url: "http://127.0.0.1:1", Timeout: time.Second}} + cfgAPI := &config.StorageConfig{Type: "api", API: config.APIStorageConfig{Url: "http://127.0.0.1:1", Timeout: time.Second, IgnoreInvalidConfig: false}} stAPI, err := NewStore(logger, cfgAPI) assert.NoError(t, err) assert.NotNil(t, stAPI) diff --git a/internal/mcp/storage/model.go b/internal/mcp/storage/model.go index 7e3319a4..7ad0ad16 100644 --- a/internal/mcp/storage/model.go +++ b/internal/mcp/storage/model.go @@ -7,9 +7,10 @@ import ( "fmt" "time" + "gorm.io/gorm" + "github.com/amoylab/unla/internal/common/cnst" "github.com/amoylab/unla/internal/common/config" - "gorm.io/gorm" ) // MCPConfig represents the database model for MCPConfig @@ -36,29 +37,32 @@ func (m *MCPConfig) ToMCPConfig() (*config.MCPConfig, error) { UpdatedAt: m.UpdatedAt, } + wrapError := func(context string, err error) error { + return fmt.Errorf("failed to unmarshal MCP configuration '%s' (tenant: '%s'): %w", m.Name, m.Tenant, err) + } if len(m.Routers) > 0 { if err := json.Unmarshal([]byte(m.Routers), &cfg.Routers); err != nil { - return nil, err + return nil, wrapError("Routers", err) } } if len(m.Servers) > 0 { if err := json.Unmarshal([]byte(m.Servers), &cfg.Servers); err != nil { - return nil, err + return nil, wrapError("Servers", err) } } if len(m.Tools) > 0 { if err := json.Unmarshal([]byte(m.Tools), &cfg.Tools); err != nil { - return nil, err + return nil, wrapError("Tools", err) } } if len(m.Prompts) > 0 { if err := json.Unmarshal([]byte(m.Prompts), &cfg.Prompts); err != nil { - return nil, err + return nil, wrapError("Prompts", err) } } if len(m.McpServers) > 0 { if err := json.Unmarshal([]byte(m.McpServers), &cfg.McpServers); err != nil { - return nil, err + return nil, wrapError("McpServers", err) } } diff --git a/pkg/mcp/server_types.go b/pkg/mcp/server_types.go index b41379e6..7dd5e8c6 100644 --- a/pkg/mcp/server_types.go +++ b/pkg/mcp/server_types.go @@ -391,6 +391,22 @@ func NewCallToolResultText(text string) *CallToolResult { } } +// NewCallToolResultTextWithError creates a new CallToolResult with text content and error flag +// @param text the text content +// @param isError indicates if the result is an error +// @return *CallToolResult the CallToolResult object with the text content and error flag +func NewCallToolResultTextWithError(text string, isError bool) *CallToolResult { + return &CallToolResult{ + Content: []Content{ + &TextContent{ + Type: TextContentType, + Text: text, + }, + }, + IsError: isError, + } +} + // NewCallToolResultImage creates a new CallToolResult with an image content // @param imageData the image data in base64 format // @param mimeType the MIME type of the image (e.g., "image/png", "image/jpeg")