diff --git a/_mocks/opencsg.com/csghub-server/aigateway/component/mock_MCPResourceComponent.go b/_mocks/opencsg.com/csghub-server/aigateway/component/mock_MCPResourceComponent.go new file mode 100644 index 000000000..e6838490d --- /dev/null +++ b/_mocks/opencsg.com/csghub-server/aigateway/component/mock_MCPResourceComponent.go @@ -0,0 +1,105 @@ +// Code generated by mockery v2.53.0. DO NOT EDIT. + +package component + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + database "opencsg.com/csghub-server/builder/store/database" + + types "opencsg.com/csghub-server/common/types" +) + +// MockMCPResourceComponent is an autogenerated mock type for the MCPResourceComponent type +type MockMCPResourceComponent struct { + mock.Mock +} + +type MockMCPResourceComponent_Expecter struct { + mock *mock.Mock +} + +func (_m *MockMCPResourceComponent) EXPECT() *MockMCPResourceComponent_Expecter { + return &MockMCPResourceComponent_Expecter{mock: &_m.Mock} +} + +// List provides a mock function with given fields: ctx, filter +func (_m *MockMCPResourceComponent) List(ctx context.Context, filter *types.MCPFilter) ([]database.MCPResource, int, error) { + ret := _m.Called(ctx, filter) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []database.MCPResource + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, *types.MCPFilter) ([]database.MCPResource, int, error)); ok { + return rf(ctx, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.MCPFilter) []database.MCPResource); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.MCPResource) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.MCPFilter) int); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(context.Context, *types.MCPFilter) error); ok { + r2 = rf(ctx, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockMCPResourceComponent_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockMCPResourceComponent_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - filter *types.MCPFilter +func (_e *MockMCPResourceComponent_Expecter) List(ctx interface{}, filter interface{}) *MockMCPResourceComponent_List_Call { + return &MockMCPResourceComponent_List_Call{Call: _e.mock.On("List", ctx, filter)} +} + +func (_c *MockMCPResourceComponent_List_Call) Run(run func(ctx context.Context, filter *types.MCPFilter)) *MockMCPResourceComponent_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.MCPFilter)) + }) + return _c +} + +func (_c *MockMCPResourceComponent_List_Call) Return(_a0 []database.MCPResource, _a1 int, _a2 error) *MockMCPResourceComponent_List_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *MockMCPResourceComponent_List_Call) RunAndReturn(run func(context.Context, *types.MCPFilter) ([]database.MCPResource, int, error)) *MockMCPResourceComponent_List_Call { + _c.Call.Return(run) + return _c +} + +// NewMockMCPResourceComponent creates a new instance of MockMCPResourceComponent. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMCPResourceComponent(t interface { + mock.TestingT + Cleanup(func()) +}) *MockMCPResourceComponent { + mock := &MockMCPResourceComponent{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/_mocks/opencsg.com/csghub-server/builder/store/database/mock_MCPResourceStore.go b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_MCPResourceStore.go new file mode 100644 index 000000000..24d43be5c --- /dev/null +++ b/_mocks/opencsg.com/csghub-server/builder/store/database/mock_MCPResourceStore.go @@ -0,0 +1,270 @@ +// Code generated by mockery v2.53.0. DO NOT EDIT. + +package database + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + database "opencsg.com/csghub-server/builder/store/database" + + types "opencsg.com/csghub-server/common/types" +) + +// MockMCPResourceStore is an autogenerated mock type for the MCPResourceStore type +type MockMCPResourceStore struct { + mock.Mock +} + +type MockMCPResourceStore_Expecter struct { + mock *mock.Mock +} + +func (_m *MockMCPResourceStore) EXPECT() *MockMCPResourceStore_Expecter { + return &MockMCPResourceStore_Expecter{mock: &_m.Mock} +} + +// Create provides a mock function with given fields: ctx, input +func (_m *MockMCPResourceStore) Create(ctx context.Context, input *database.MCPResource) (*database.MCPResource, error) { + ret := _m.Called(ctx, input) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 *database.MCPResource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *database.MCPResource) (*database.MCPResource, error)); ok { + return rf(ctx, input) + } + if rf, ok := ret.Get(0).(func(context.Context, *database.MCPResource) *database.MCPResource); ok { + r0 = rf(ctx, input) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*database.MCPResource) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *database.MCPResource) error); ok { + r1 = rf(ctx, input) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMCPResourceStore_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type MockMCPResourceStore_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - input *database.MCPResource +func (_e *MockMCPResourceStore_Expecter) Create(ctx interface{}, input interface{}) *MockMCPResourceStore_Create_Call { + return &MockMCPResourceStore_Create_Call{Call: _e.mock.On("Create", ctx, input)} +} + +func (_c *MockMCPResourceStore_Create_Call) Run(run func(ctx context.Context, input *database.MCPResource)) *MockMCPResourceStore_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*database.MCPResource)) + }) + return _c +} + +func (_c *MockMCPResourceStore_Create_Call) Return(_a0 *database.MCPResource, _a1 error) *MockMCPResourceStore_Create_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMCPResourceStore_Create_Call) RunAndReturn(run func(context.Context, *database.MCPResource) (*database.MCPResource, error)) *MockMCPResourceStore_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, input +func (_m *MockMCPResourceStore) Delete(ctx context.Context, input *database.MCPResource) error { + ret := _m.Called(ctx, input) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *database.MCPResource) error); ok { + r0 = rf(ctx, input) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockMCPResourceStore_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockMCPResourceStore_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - input *database.MCPResource +func (_e *MockMCPResourceStore_Expecter) Delete(ctx interface{}, input interface{}) *MockMCPResourceStore_Delete_Call { + return &MockMCPResourceStore_Delete_Call{Call: _e.mock.On("Delete", ctx, input)} +} + +func (_c *MockMCPResourceStore_Delete_Call) Run(run func(ctx context.Context, input *database.MCPResource)) *MockMCPResourceStore_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*database.MCPResource)) + }) + return _c +} + +func (_c *MockMCPResourceStore_Delete_Call) Return(_a0 error) *MockMCPResourceStore_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockMCPResourceStore_Delete_Call) RunAndReturn(run func(context.Context, *database.MCPResource) error) *MockMCPResourceStore_Delete_Call { + _c.Call.Return(run) + return _c +} + +// List provides a mock function with given fields: ctx, filter +func (_m *MockMCPResourceStore) List(ctx context.Context, filter *types.MCPFilter) ([]database.MCPResource, int, error) { + ret := _m.Called(ctx, filter) + + if len(ret) == 0 { + panic("no return value specified for List") + } + + var r0 []database.MCPResource + var r1 int + var r2 error + if rf, ok := ret.Get(0).(func(context.Context, *types.MCPFilter) ([]database.MCPResource, int, error)); ok { + return rf(ctx, filter) + } + if rf, ok := ret.Get(0).(func(context.Context, *types.MCPFilter) []database.MCPResource); ok { + r0 = rf(ctx, filter) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]database.MCPResource) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *types.MCPFilter) int); ok { + r1 = rf(ctx, filter) + } else { + r1 = ret.Get(1).(int) + } + + if rf, ok := ret.Get(2).(func(context.Context, *types.MCPFilter) error); ok { + r2 = rf(ctx, filter) + } else { + r2 = ret.Error(2) + } + + return r0, r1, r2 +} + +// MockMCPResourceStore_List_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'List' +type MockMCPResourceStore_List_Call struct { + *mock.Call +} + +// List is a helper method to define mock.On call +// - ctx context.Context +// - filter *types.MCPFilter +func (_e *MockMCPResourceStore_Expecter) List(ctx interface{}, filter interface{}) *MockMCPResourceStore_List_Call { + return &MockMCPResourceStore_List_Call{Call: _e.mock.On("List", ctx, filter)} +} + +func (_c *MockMCPResourceStore_List_Call) Run(run func(ctx context.Context, filter *types.MCPFilter)) *MockMCPResourceStore_List_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*types.MCPFilter)) + }) + return _c +} + +func (_c *MockMCPResourceStore_List_Call) Return(_a0 []database.MCPResource, _a1 int, _a2 error) *MockMCPResourceStore_List_Call { + _c.Call.Return(_a0, _a1, _a2) + return _c +} + +func (_c *MockMCPResourceStore_List_Call) RunAndReturn(run func(context.Context, *types.MCPFilter) ([]database.MCPResource, int, error)) *MockMCPResourceStore_List_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, input +func (_m *MockMCPResourceStore) Update(ctx context.Context, input *database.MCPResource) (*database.MCPResource, error) { + ret := _m.Called(ctx, input) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 *database.MCPResource + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *database.MCPResource) (*database.MCPResource, error)); ok { + return rf(ctx, input) + } + if rf, ok := ret.Get(0).(func(context.Context, *database.MCPResource) *database.MCPResource); ok { + r0 = rf(ctx, input) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*database.MCPResource) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *database.MCPResource) error); ok { + r1 = rf(ctx, input) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockMCPResourceStore_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockMCPResourceStore_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - input *database.MCPResource +func (_e *MockMCPResourceStore_Expecter) Update(ctx interface{}, input interface{}) *MockMCPResourceStore_Update_Call { + return &MockMCPResourceStore_Update_Call{Call: _e.mock.On("Update", ctx, input)} +} + +func (_c *MockMCPResourceStore_Update_Call) Run(run func(ctx context.Context, input *database.MCPResource)) *MockMCPResourceStore_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*database.MCPResource)) + }) + return _c +} + +func (_c *MockMCPResourceStore_Update_Call) Return(_a0 *database.MCPResource, _a1 error) *MockMCPResourceStore_Update_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockMCPResourceStore_Update_Call) RunAndReturn(run func(context.Context, *database.MCPResource) (*database.MCPResource, error)) *MockMCPResourceStore_Update_Call { + _c.Call.Return(run) + return _c +} + +// NewMockMCPResourceStore creates a new instance of MockMCPResourceStore. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockMCPResourceStore(t interface { + mock.TestingT + Cleanup(func()) +}) *MockMCPResourceStore { + mock := &MockMCPResourceStore{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/aigateway/component/mcp_resource.go b/aigateway/component/mcp_resource.go new file mode 100644 index 000000000..fa269969c --- /dev/null +++ b/aigateway/component/mcp_resource.go @@ -0,0 +1,27 @@ +package component + +import ( + "context" + + "opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/common/config" + "opencsg.com/csghub-server/common/types" +) + +type MCPResourceComponent interface { + List(ctx context.Context, filter *types.MCPFilter) ([]database.MCPResource, int, error) +} + +type mcpResourceComponentImpl struct { + mcpResStore database.MCPResourceStore +} + +func NewMCPResourceComponent(config *config.Config) MCPResourceComponent { + return &mcpResourceComponentImpl{ + mcpResStore: database.NewMCPResourceStore(), + } +} + +func (c *mcpResourceComponentImpl) List(ctx context.Context, filter *types.MCPFilter) ([]database.MCPResource, int, error) { + return c.mcpResStore.List(ctx, filter) +} diff --git a/aigateway/component/mcp_resource_test.go b/aigateway/component/mcp_resource_test.go new file mode 100644 index 000000000..16851db26 --- /dev/null +++ b/aigateway/component/mcp_resource_test.go @@ -0,0 +1,42 @@ +package component + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + mockdb "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/common/types" +) + +func NewTestMCPResourceComponent(mcpResStore database.MCPResourceStore) MCPResourceComponent { + mrc := &mcpResourceComponentImpl{ + mcpResStore: mcpResStore, + } + return mrc +} + +func TestMCPResourceComponent_List(t *testing.T) { + ctx := context.TODO() + + filter := &types.MCPFilter{ + Per: 10, + Page: 1, + } + + resStore := mockdb.NewMockMCPResourceStore(t) + resStore.EXPECT().List(ctx, filter).Return([]database.MCPResource{ + { + ID: 1, + Name: "test-name", + }, + }, 1, nil) + + testComp := NewTestMCPResourceComponent(resStore) + + resList, total, err := testComp.List(ctx, filter) + require.Nil(t, err) + require.Equal(t, 1, total) + require.Len(t, resList, 1) +} diff --git a/aigateway/handler/mcp.go b/aigateway/handler/mcp.go index 816cf812f..62603678a 100644 --- a/aigateway/handler/mcp.go +++ b/aigateway/handler/mcp.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gin-gonic/gin" + gwcomp "opencsg.com/csghub-server/aigateway/component" "opencsg.com/csghub-server/api/httpbase" "opencsg.com/csghub-server/builder/proxy" "opencsg.com/csghub-server/common/config" @@ -18,12 +19,13 @@ import ( ) type MCPProxyHandler interface { - List(c *gin.Context) + Resources(c *gin.Context) ProxyToApi(api string) gin.HandlerFunc } type MCPProxyHandlerImpl struct { - spaceComp component.SpaceComponent + spaceComp component.SpaceComponent + mcpResComp gwcomp.MCPResourceComponent } func NewMCPProxyHandler(config *config.Config) (MCPProxyHandler, error) { @@ -31,46 +33,13 @@ func NewMCPProxyHandler(config *config.Config) (MCPProxyHandler, error) { if err != nil { return nil, fmt.Errorf("failed to create space component,%w", err) } + mcpResComp := gwcomp.NewMCPResourceComponent(config) return &MCPProxyHandlerImpl{ - spaceComp: spaceComp, + spaceComp: spaceComp, + mcpResComp: mcpResComp, }, nil } -// ListMCPs godoc -// @Security ApiKey -// @Summary List available mcp servers -// @Description Returns a list of available mcp servers -// @Tags AIGateway -// @Accept json -// @Produce json -// @Param per query int false "per" default(20) -// @Param page query int false "per page" default(1) -// @Success 200 {object} types.ResponseWithTotal{data=[]types.MCPService,total=int} "OK" -// @Failure 500 {object} error "Internal server error" -// @Router /v1/mcp/servers [get] -func (m *MCPProxyHandlerImpl) List(ctx *gin.Context) { - repoFilter := new(types.RepoFilter) - repoFilter.Username = httpbase.GetCurrentUser(ctx) - repoFilter.SpaceSDK = types.MCPSERVER.Name - per, page, err := common.GetPerAndPageFromContext(ctx) - if err != nil { - slog.Error("Bad request format for pagination", "error", err) - httpbase.BadRequest(ctx, err.Error()) - return - } - mcps, total, err := m.spaceComp.MCPIndex(ctx.Request.Context(), repoFilter, per, page) - if err != nil { - slog.Error("Failed to get mcp service", slog.Any("error", err)) - httpbase.ServerError(ctx, err) - return - } - respData := gin.H{ - "data": mcps, - "total": total, - } - ctx.JSON(http.StatusOK, respData) -} - // proxy to mcp service func (m *MCPProxyHandlerImpl) ProxyToApi(api string) gin.HandlerFunc { return func(ctx *gin.Context) { @@ -107,3 +76,39 @@ func (m *MCPProxyHandlerImpl) ProxyToApi(api string) gin.HandlerFunc { rp.ServeHTTP(ctx.Writer, ctx.Request, api, "") } } + +// ListRecommendedMCPs godoc +// @Security ApiKey +// @Summary List recommanded mcp servers +// @Description Returns a list of recommended mcp servers +// @Tags AIGateway +// @Accept json +// @Produce json +// @Param per query int false "per" default(50) +// @Param page query int false "per page" default(1) +// @Success 200 {object} types.ResponseWithTotal{data=[],total=int} "OK" +// @Failure 500 {object} error "Internal server error" +// @Router /v1/mcp/resources [get] +func (m *MCPProxyHandlerImpl) Resources(ctx *gin.Context) { + mcpFilter := new(types.MCPFilter) + mcpFilter.Username = httpbase.GetCurrentUser(ctx) + per, page, err := common.GetPerAndPageFromContext(ctx) + if err != nil { + slog.ErrorContext(ctx, "Bad request format for pagination", slog.Any("error", err)) + httpbase.BadRequest(ctx, err.Error()) + return + } + mcpFilter.Per = per + mcpFilter.Page = page + mcps, total, err := m.mcpResComp.List(ctx.Request.Context(), mcpFilter) + if err != nil { + slog.ErrorContext(ctx, "Failed to get mcp resources", slog.Any("error", err)) + httpbase.ServerError(ctx, err) + return + } + respData := gin.H{ + "data": mcps, + "total": total, + } + ctx.JSON(http.StatusOK, respData) +} diff --git a/aigateway/handler/mcp_test.go b/aigateway/handler/mcp_test.go index b93312d80..0c9fe4d4f 100644 --- a/aigateway/handler/mcp_test.go +++ b/aigateway/handler/mcp_test.go @@ -11,36 +11,42 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" + gwmockcomp "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/aigateway/component" apicomp "opencsg.com/csghub-server/_mocks/opencsg.com/csghub-server/component" + gwcomp "opencsg.com/csghub-server/aigateway/component" "opencsg.com/csghub-server/api/httpbase" + "opencsg.com/csghub-server/builder/store/database" comType "opencsg.com/csghub-server/common/types" "opencsg.com/csghub-server/component" ) -func NewTestMCPProxyHandler(mockSpaceComp component.SpaceComponent) (MCPProxyHandler, error) { +func NewTestMCPProxyHandler(mockSpaceComp component.SpaceComponent, mockMCPResComp gwcomp.MCPResourceComponent) (MCPProxyHandler, error) { return &MCPProxyHandlerImpl{ - spaceComp: mockSpaceComp, + spaceComp: mockSpaceComp, + mcpResComp: mockMCPResComp, }, nil } -func TestOpenAIHandler_List(t *testing.T) { +func TestMCPHandler_ResourceList(t *testing.T) { mockSpaceComp := apicomp.NewMockSpaceComponent(t) + mockMCPResComp := gwmockcomp.NewMockMCPResourceComponent(t) - handler, err := NewTestMCPProxyHandler(mockSpaceComp) + handler, err := NewTestMCPProxyHandler(mockSpaceComp, mockMCPResComp) require.Nil(t, err) - repoFilter := new(comType.RepoFilter) - repoFilter.Username = "testuser" - repoFilter.SpaceSDK = comType.MCPSERVER.Name + filter := new(comType.MCPFilter) + filter.Username = "testuser" + filter.Page = 1 + filter.Per = 10 - mcps := []*comType.MCPService{ + mcps := []database.MCPResource{ { ID: 1, Name: "mcp1", }, } - mockSpaceComp.EXPECT().MCPIndex(mock.Anything, repoFilter, 10, 1).Return(mcps, 1, nil) + mockMCPResComp.EXPECT().List(mock.Anything, filter).Return(mcps, 1, nil) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) @@ -52,13 +58,13 @@ func TestOpenAIHandler_List(t *testing.T) { } httpbase.SetCurrentUser(c, "testuser") - handler.List(c) + handler.Resources(c) assert.Equal(t, http.StatusOK, w.Code) type Resp struct { - Total int `json:"total"` - Data []*comType.MCPService `json:"data"` + Total int `json:"total"` + Data []database.MCPResource `json:"data"` } var response Resp err = json.Unmarshal(w.Body.Bytes(), &response) diff --git a/aigateway/router/aigateway.go b/aigateway/router/aigateway.go index d03d101cd..f8ee22eee 100644 --- a/aigateway/router/aigateway.go +++ b/aigateway/router/aigateway.go @@ -41,7 +41,7 @@ func NewRouter(config *config.Config) (*gin.Engine, error) { func CreateMCPRoute(v1Group *gin.RouterGroup, mcpProxy handler.MCPProxyHandler) { mcpGroup := v1Group.Group("mcp") - mcpGroup.GET("/servers", mcpProxy.List) + mcpGroup.GET("/resources", mcpProxy.Resources) // todo: enable mcp server proxy later mcpGroup.Any("/:servicename/*any", mcpProxy.ProxyToApi("")) diff --git a/builder/store/database/mcp_resource.go b/builder/store/database/mcp_resource.go new file mode 100644 index 000000000..9aeb3f807 --- /dev/null +++ b/builder/store/database/mcp_resource.go @@ -0,0 +1,88 @@ +package database + +import ( + "context" + + "opencsg.com/csghub-server/common/errorx" + "opencsg.com/csghub-server/common/types" +) + +type MCPResource struct { + ID int64 `bun:",pk,autoincrement" json:"id"` + Name string `bun:",notnull" json:"name"` + Description string `bun:",notnull" json:"description"` + Owner string `bun:",nullzero" json:"owner"` + Avatar string `bun:",nullzero" json:"avatar"` + Url string `bun:",notnull" json:"url"` + Protocol string `bun:",notnull" json:"protocol"` // sse/streamable + Headers map[string]any `bun:"type:jsonb,nullzero" json:"headers"` + NeedInstall bool `bun:",notnull,default:false" json:"need_install"` // set this to true if the headers need to be set by the user or some other before use + times +} + +type MCPResourceStore interface { + Create(ctx context.Context, input *MCPResource) (*MCPResource, error) + Update(ctx context.Context, input *MCPResource) (*MCPResource, error) + Delete(ctx context.Context, input *MCPResource) error + List(ctx context.Context, filter *types.MCPFilter) ([]MCPResource, int, error) +} + +type mcpResourceStoreImpl struct { + db *DB +} + +func NewMCPResourceStore() MCPResourceStore { + return &mcpResourceStoreImpl{ + db: defaultDB, + } +} + +func NewMCPResourceStoreWithDB(db *DB) MCPResourceStore { + return &mcpResourceStoreImpl{ + db: db, + } +} + +func (m *mcpResourceStoreImpl) Create(ctx context.Context, input *MCPResource) (*MCPResource, error) { + res, err := m.db.Core.NewInsert().Model(input).Exec(ctx, input) + if err := assertAffectedOneRow(res, err); err != nil { + return nil, errorx.HandleDBError(err, nil) + } + return input, nil +} + +func (m *mcpResourceStoreImpl) Update(ctx context.Context, input *MCPResource) (*MCPResource, error) { + res, err := m.db.Core.NewUpdate().Model(input).WherePK().Exec(ctx) + if err := assertAffectedOneRow(res, err); err != nil { + return nil, errorx.HandleDBError(err, nil) + } + return input, nil +} + +func (m *mcpResourceStoreImpl) Delete(ctx context.Context, input *MCPResource) error { + res, err := m.db.Operator.Core.NewDelete().Model(input).WherePK().Exec(ctx) + if err := assertAffectedOneRow(res, err); err != nil { + return errorx.HandleDBError(err, nil) + } + return nil +} + +func (m *mcpResourceStoreImpl) List(ctx context.Context, filter *types.MCPFilter) ([]MCPResource, int, error) { + var mcpResList []MCPResource + var count int + q := m.db.Operator.Core.NewSelect().Model(&mcpResList) + + count, err := q.Count(ctx) + if err != nil { + return nil, 0, errorx.HandleDBError(err, nil) + } + + q = q.Order("id DESC") + q = q.Limit(filter.Per).Offset((filter.Page - 1) * filter.Per) + err = q.Scan(ctx, &mcpResList) + if err != nil { + return nil, 0, errorx.HandleDBError(err, nil) + } + + return mcpResList, count, nil +} diff --git a/builder/store/database/mcp_resource_test.go b/builder/store/database/mcp_resource_test.go new file mode 100644 index 000000000..5754d49b5 --- /dev/null +++ b/builder/store/database/mcp_resource_test.go @@ -0,0 +1,54 @@ +package database_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "opencsg.com/csghub-server/builder/store/database" + "opencsg.com/csghub-server/common/tests" + "opencsg.com/csghub-server/common/types" +) + +func TestMCPResource_CURD(t *testing.T) { + db := tests.InitTestDB() + defer db.Close() + ctx := context.TODO() + + store := database.NewMCPResourceStoreWithDB(db) + + res := &database.MCPResource{ + Name: "test-name", + Description: "test-desc", + Owner: "OpenCSG", + Avatar: "test-avatar", + Url: "https://test.com", + Protocol: "sse", + Headers: map[string]any{ + "key1": "value1", + }, + } + + res, err := store.Create(ctx, res) + require.Nil(t, err) + require.Equal(t, res.Name, "test-name") + + filter := &types.MCPFilter{ + Per: 10, + Page: 1, + } + + resList, total, err := store.List(ctx, filter) + require.Nil(t, err) + require.Equal(t, total, 1) + require.Equal(t, resList[0].Name, "test-name") + + res.Name = "updated-name" + newRes, err := store.Update(ctx, res) + require.Nil(t, err) + require.Equal(t, newRes.Name, "updated-name") + + err = store.Delete(ctx, res) + require.Nil(t, err) + +} diff --git a/builder/store/database/migrations/20251210012031_create_table_mcp_resources.go b/builder/store/database/migrations/20251210012031_create_table_mcp_resources.go new file mode 100644 index 000000000..8bda1f3ea --- /dev/null +++ b/builder/store/database/migrations/20251210012031_create_table_mcp_resources.go @@ -0,0 +1,29 @@ +package migrations + +import ( + "context" + + "github.com/uptrace/bun" +) + +type MCPResource struct { + ID int64 `bun:",pk,autoincrement" json:"id"` + Name string `bun:",notnull" json:"name"` + Description string `bun:",notnull" json:"description"` + Owner string `bun:",nullzero" json:"owner"` + Avatar string `bun:",nullzero" json:"avatar"` + Url string `bun:",notnull" json:"url"` + Protocol string `bun:",notnull" json:"protocol"` // sse/streamable + Headers map[string]any `bun:"type:jsonb,nullzero" json:"headers"` + NeedInstall bool `bun:",notnull,default:false" json:"need_install"` + times +} + +func init() { + Migrations.MustRegister(func(ctx context.Context, db *bun.DB) error { + err := createTables(ctx, db, &MCPResource{}) + return err + }, func(ctx context.Context, db *bun.DB) error { + return dropTables(ctx, db, &MCPResource{}) + }) +} diff --git a/common/types/mcp.go b/common/types/mcp.go index 6869a0516..9f2a0f116 100644 --- a/common/types/mcp.go +++ b/common/types/mcp.go @@ -116,3 +116,9 @@ type MCPSpaceConfig struct { BuildCmds string `json:"build_cmds"` LaunchCmds string `json:"launch_cmds"` } + +type MCPFilter struct { + Username string + Per int + Page int +}