From 69dbc44983c94f342ecb3acd4fe5dccf2fbcad3d Mon Sep 17 00:00:00 2001 From: Hz_ Date: Thu, 18 Jun 2026 11:09:22 +0800 Subject: [PATCH] feat(go-api): migrate MCP server detail and download API to Go (#16113) ### What problem does this PR solve? - Migrated MCP server detail and export (download) API from Python to Go. - Registered route: `GET /api/v1/mcp/servers/:mcp_id` (supporting `?mode=download` query parameter). --- internal/handler/mcp.go | 50 ++++++++++++++++++++++++++++ internal/handler/mcp_test.go | 31 ++++++++++++++++++ internal/router/router.go | 3 +- internal/service/mcp.go | 61 ++++++++++++++++++++++++++++++++++ internal/service/mcp_test.go | 63 ++++++++++++++++++++++++++++++++++++ 5 files changed, 206 insertions(+), 2 deletions(-) diff --git a/internal/handler/mcp.go b/internal/handler/mcp.go index 4b5bcd9f2a..d4118cd1e9 100644 --- a/internal/handler/mcp.go +++ b/internal/handler/mcp.go @@ -139,6 +139,56 @@ func (h *MCPHandler) ListMCPServers(c *gin.Context) { }) } +func (h *MCPHandler) GetMCPServer(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + mcpID := c.Param("mcp_id") + if c.Query("mode") == "download" { + result, code, err := h.mcpService.ExportMCPServer(user.ID, mcpID) + if err != nil { + mcpDetailError(c, code, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "message": "success", + "data": result, + }) + return + } + + result, code, err := h.mcpService.GetMCPServer(user.ID, mcpID) + if err != nil { + mcpDetailError(c, code, err) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeSuccess, + "message": "success", + "data": newMCPServerResponse(result), + }) +} + +func mcpDetailError(c *gin.Context, code common.ErrorCode, err error) { + if code == common.CodeDataError { + c.JSON(http.StatusOK, gin.H{ + "code": code, + "message": err.Error(), + }) + return + } + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeExceptionError, + "message": err.Error(), + "data": nil, + }) +} + + // UpdateMCPServer updates an MCP server for the current user. func (h *MCPHandler) UpdateMCPServer(c *gin.Context) { user, errorCode, errorMessage := GetUser(c) diff --git a/internal/handler/mcp_test.go b/internal/handler/mcp_test.go index 7538bbfa7c..4348412450 100644 --- a/internal/handler/mcp_test.go +++ b/internal/handler/mcp_test.go @@ -19,9 +19,15 @@ package handler import ( "bytes" "encoding/json" + "errors" + "net/http" + "net/http/httptest" "testing" "time" + "github.com/gin-gonic/gin" + + "ragflow/internal/common" "ragflow/internal/entity" ) @@ -69,3 +75,28 @@ func TestNewMCPServerResponsePreservesNullDescriptionAndFormatsDates(t *testing. t.Fatalf("payload %s includes timezone in date fields", payload) } } + +func TestMCPDetailDataErrorOmitsDataFieldLikePython(t *testing.T) { + gin.SetMode(gin.TestMode) + recorder := httptest.NewRecorder() + c, _ := gin.CreateTestContext(recorder) + + mcpDetailError(c, common.CodeDataError, errors.New("Cannot find MCP server mcp-id for user user-id")) + + if recorder.Code != http.StatusOK { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusOK) + } + var body map[string]interface{} + if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil { + t.Fatalf("unmarshal body: %v", err) + } + if body["code"] != float64(common.CodeDataError) { + t.Fatalf("code = %v, want %d", body["code"], common.CodeDataError) + } + if body["message"] != "Cannot find MCP server mcp-id for user user-id" { + t.Fatalf("message = %v", body["message"]) + } + if _, ok := body["data"]; ok { + t.Fatalf("body unexpectedly contains data field: %s", recorder.Body.String()) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 2dffcce29f..ca0131a6c7 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -351,8 +351,6 @@ func (r *Router) Setup(engine *gin.Engine) { commitDatasets.GET("/changes", r.fileCommitHandler.GetUncommittedChanges) } - - // Author routes authors := v1.Group("/authors") { @@ -500,6 +498,7 @@ func (r *Router) Setup(engine *gin.Engine) { { mcp.POST("/servers", r.mcpHandler.CreateMCPServer) mcp.GET("/servers", r.mcpHandler.ListMCPServers) + mcp.GET("/servers/:mcp_id", r.mcpHandler.GetMCPServer) mcp.PUT("/servers/:mcp_id", r.mcpHandler.UpdateMCPServer) mcp.DELETE("/servers/:mcp_id", r.mcpHandler.DeleteMCPServer) mcp.POST("/servers/import", r.mcpHandler.ImportMCPServers) diff --git a/internal/service/mcp.go b/internal/service/mcp.go index 6a543c7428..65f48c0c87 100644 --- a/internal/service/mcp.go +++ b/internal/service/mcp.go @@ -92,6 +92,18 @@ type MCPServerListItem struct { UpdateDate *string `json:"update_date"` } +type ExportMCPServer struct { + Type string `json:"type"` + URL string `json:"url"` + Name string `json:"name"` + AuthorizationToken interface{} `json:"authorization_token"` + Tools interface{} `json:"tools"` +} + +type ExportMCPServerResponse struct { + MCPServers map[string]ExportMCPServer `json:"mcpServers"` +} + // ListMCPServersResponse is the response payload for listing MCP servers. type ListMCPServersResponse struct { MCPServers []*MCPServerListItem `json:"mcp_servers"` @@ -156,6 +168,55 @@ func (s *MCPService) CreateMCPServer(tenantID string, req CreateMCPServerRequest }, common.CodeSuccess, nil } +func (s *MCPService) GetMCPServer(tenantID, mcpID string) (*entity.MCPServer, common.ErrorCode, error) { + server, err := s.mcpServerDAO.GetByIDAndTenant(mcpID, tenantID) + if err != nil { + if isMCPServerNotFound(err) { + return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + } + return nil, common.CodeServerError, fmt.Errorf("failed to get MCP server %s: %w", mcpID, err) + } + if server == nil { + return nil, common.CodeDataError, mcpServerNotFoundError(mcpID, tenantID) + } + return server, common.CodeSuccess, nil +} + +func (s *MCPService) ExportMCPServer(userID, mcpID string) (*ExportMCPServerResponse, common.ErrorCode, error) { + server, code, err := s.GetMCPServer(userID, mcpID) + if err != nil { + return nil, code, err + } + return newExportMCPServerResponse(server), common.CodeSuccess, nil +} + +func newExportMCPServerResponse(server *entity.MCPServer) *ExportMCPServerResponse { + vars := server.Variables + if vars == nil { + vars = entity.JSONMap{} + } + + token := interface{}("") + if value, ok := vars["authorization_token"]; ok { + token = value + } + tools := vars["tools"] + if tools == nil { + tools = map[string]interface{}{} + } + return &ExportMCPServerResponse{ + MCPServers: map[string]ExportMCPServer{ + server.Name: { + Type: server.ServerType, + URL: server.URL, + Name: server.Name, + AuthorizationToken: token, + Tools: tools, + }, + }, + } +} + // UpdateMCPServer updates an MCP server owned by a tenant. func (s *MCPService) UpdateMCPServer(tenantID, mcpID string, req UpdateMCPServerRequest) (*entity.MCPServer, common.ErrorCode, error) { server, err := s.mcpServerDAO.GetByIDAndTenant(mcpID, tenantID) diff --git a/internal/service/mcp_test.go b/internal/service/mcp_test.go index 507fbd1889..cdbece62b9 100644 --- a/internal/service/mcp_test.go +++ b/internal/service/mcp_test.go @@ -17,6 +17,7 @@ package service import ( + "encoding/json" "errors" "fmt" "strings" @@ -86,6 +87,68 @@ func TestImportServersValidationErrors(t *testing.T) { } } +func TestNewExportMCPServerResponseMatchesPythonDownloadShape(t *testing.T) { + response := newExportMCPServerResponse(&entity.MCPServer{ + Name: "weather", + URL: "https://example.com/mcp", + ServerType: mcpServerTypeStreamableHTTP, + Variables: entity.JSONMap{ + "authorization_token": "secret-token", + "tools": map[string]interface{}{ + "forecast": map[string]interface{}{"name": "forecast"}, + }, + }, + }) + + payload, err := json.Marshal(response) + if err != nil { + t.Fatalf("marshal response: %v", err) + } + + var decoded map[string]map[string]map[string]interface{} + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + server := decoded["mcpServers"]["weather"] + if server["type"] != mcpServerTypeStreamableHTTP { + t.Fatalf("type = %v, want %s", server["type"], mcpServerTypeStreamableHTTP) + } + if server["url"] != "https://example.com/mcp" { + t.Fatalf("url = %v", server["url"]) + } + if server["name"] != "weather" { + t.Fatalf("name = %v", server["name"]) + } + if server["authorization_token"] != "secret-token" { + t.Fatalf("authorization_token = %v", server["authorization_token"]) + } + tools, ok := server["tools"].(map[string]interface{}) + if !ok || tools["forecast"] == nil { + t.Fatalf("tools = %#v, want forecast tool", server["tools"]) + } +} + +func TestNewExportMCPServerResponseDefaultsMissingVariablesLikePython(t *testing.T) { + response := newExportMCPServerResponse(&entity.MCPServer{ + Name: "empty-vars", + URL: "https://example.com/mcp", + ServerType: mcpServerTypeSSE, + }) + + server := response.MCPServers["empty-vars"] + if server.AuthorizationToken != "" { + t.Fatalf("authorization_token = %#v, want empty string", server.AuthorizationToken) + } + tools, ok := server.Tools.(map[string]interface{}) + if !ok { + t.Fatalf("tools type = %T, want map[string]interface{}", server.Tools) + } + if len(tools) != 0 { + t.Fatalf("tools = %#v, want empty map", tools) + } +} + func TestPaginateMCPServersNegativeValuesMatchPythonSlice(t *testing.T) { servers := makeMCPServers(13)