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
1 change: 1 addition & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 4 additions & 3 deletions internal/common/config/storage.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
)
3 changes: 2 additions & 1 deletion internal/core/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
59 changes: 48 additions & 11 deletions internal/mcp/storage/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down
15 changes: 8 additions & 7 deletions internal/mcp/storage/api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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")
Expand All @@ -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")
Expand All @@ -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))
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/mcp/storage/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
5 changes: 3 additions & 2 deletions internal/mcp/storage/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
Expand Down
16 changes: 10 additions & 6 deletions internal/mcp/storage/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
}

Expand Down
16 changes: 16 additions & 0 deletions pkg/mcp/server_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading