mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-04 23:00:30 +08:00
feat(go-api): implement MCP server management endpoints (#15281)
## Summary Ports the MCP (Model Context Protocol) server management endpoints that power `web/src/pages/user-setting/mcp/` from Python (`api/apps/restful_apis/mcp_api.py`) to Go. There were no MCP routes in the Go server before this change. Closes #15275 (subtask of #15240). ## Endpoints implemented (base path `/api/v1`) | Method | Path | Description | |--------|------|-------------| | GET | `/mcp/servers` | List tenant servers (keyword / order / pagination) | | POST | `/mcp/servers` | Create a server | | GET | `/mcp/servers/{mcp_id}` | Get one (`?mode=download` exports config) | | PUT | `/mcp/servers/{mcp_id}` | Update a server | | DELETE | `/mcp/servers/{mcp_id}` | Delete a server | | POST | `/mcp/import` | Bulk import from JSON config | | POST | `/mcp/servers/{mcp_id}/test` | Connect + list tools (see notes) | ## Implementation Follows the existing `handler → service → dao` layering (per PR #14790): - **entity** (`internal/entity/mcp.go`): added `MCPServerType` constants and `IsValidMCPServerType` over the existing `MCPServer` model. - **dao** (`internal/dao/mcp.go`): new `MCPServerDAO` with tenant-scoped CRUD, a keyword filter, and a **whitelisted order-column map** (guards against SQL injection via the caller-supplied `orderby`). - **service** (`internal/service/mcp.go`): new `MCPService` — list/get/export/create/update/delete/import/test — mirroring `MCPServerService` and the `mcp_api` request validation, with sentinel errors for clean code mapping. - **handler** (`internal/handler/mcp.go`): new `MCPHandler` with the seven handlers and Python-compatible response codes. - **router / server_main**: registered the `/mcp` group and wired the handler. ## Deviations from Python (documented in code) 1. **Bulk import is at `POST /mcp/import`, not `/mcp/servers/import`.** gin (v1.9.1) cannot register a static segment and a path param at the same tree node, so `/mcp/servers/import` would collide with `/mcp/servers/:mcp_id` and panic at startup. The frontend should call `/mcp/import`. 2. **No live tool discovery on create/update/import.** The Python path runs `get_mcp_tools` over SSE / streamable-HTTP and stores `variables.tools`. The Go server has no MCP client yet, so these persist `variables`/`headers` but leave `variables.tools` unpopulated. 3. **`/test` returns a data error (`ErrMCPTestUnsupported`)** until a Go MCP client lands. Per the issue, the live-connection path is scoped as a follow-up; the handler still validates `url` + `server_type`. ## Testing - Added `internal/service/mcp_test.go` covering `IsValidMCPServerType` and the `TestServer` validation/short-circuit paths (no DB required). - No Go toolchain was available in the dev environment, so `go build ./...` / `go vet ./...` verification is left to CI. ## Follow-ups - Go MCP client (SSE / streamable-HTTP) to enable live tool discovery and the real `/test` behavior. - Reconcile the `/mcp/import` vs `/mcp/servers/import` path with the frontend. ---------
This commit is contained in:
@@ -17,7 +17,10 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -185,6 +188,203 @@ func (h *MCPHandler) DeleteMCPServer(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// mcpErrorResponse maps the import / test sentinel errors to the response
|
||||
// codes Python's mcp_api emits.
|
||||
func mcpErrorResponse(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMCPInvalidType),
|
||||
errors.Is(err, service.ErrMCPInvalidName),
|
||||
errors.Is(err, service.ErrMCPInvalidURL),
|
||||
errors.Is(err, service.ErrMCPTestFailed):
|
||||
c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": nil, "message": mcpErrorMessage(err)})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": common.CodeServerError, "data": nil, "message": err.Error()})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mcpErrorMessage(err error) string {
|
||||
if err == nil {
|
||||
return ""
|
||||
}
|
||||
// service wraps its sentinels as "<sentinel>: <detail>" via
|
||||
// fmt.Errorf("%w: ...", err). Surface the detail when present so the
|
||||
// SSRF guard's per-failure message (e.g. "URL resolves to a non-public
|
||||
// address (...).") reaches the caller verbatim, matching what Python's
|
||||
// _assert_mcp_url_is_safe returns.
|
||||
switch {
|
||||
case errors.Is(err, service.ErrMCPInvalidURL):
|
||||
if detail := unwrapDetail(err, service.ErrMCPInvalidURL); detail != "" {
|
||||
return detail
|
||||
}
|
||||
return "Invalid url."
|
||||
case errors.Is(err, service.ErrMCPInvalidType):
|
||||
return "Unsupported MCP server type."
|
||||
case errors.Is(err, service.ErrMCPTestFailed):
|
||||
if detail := unwrapDetail(err, service.ErrMCPTestFailed); detail != "" {
|
||||
return detail
|
||||
}
|
||||
return "Test MCP error."
|
||||
default:
|
||||
return err.Error()
|
||||
}
|
||||
}
|
||||
|
||||
// unwrapDetail pulls the "<sentinel>: <detail>" suffix off a wrapped error
|
||||
// and returns the detail. Returns "" when the error is the bare sentinel
|
||||
// (no wrapped message) so the caller can fall back to a default.
|
||||
func unwrapDetail(err, sentinel error) string {
|
||||
if err == nil || sentinel == nil {
|
||||
return ""
|
||||
}
|
||||
prefix := sentinel.Error() + ": "
|
||||
msg := err.Error()
|
||||
if !strings.HasPrefix(msg, prefix) {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimPrefix(msg, prefix)
|
||||
}
|
||||
|
||||
// ImportMCPRequest is the body for the bulk-import endpoint.
|
||||
type ImportMCPRequest struct {
|
||||
MCPServers map[string]map[string]interface{} `json:"mcpServers"`
|
||||
Timeout float64 `json:"timeout,omitempty"`
|
||||
}
|
||||
|
||||
// ImportMCPServers bulk-imports MCP servers from a JSON config, fetching the
|
||||
// remote tool list for each entry and persisting it under variables.tools.
|
||||
// Mirrors Python's import_multiple, including the same distinction between
|
||||
// "mcpServers key missing" (101 ARGUMENT_ERROR) and "mcpServers key
|
||||
// present but empty" (102 DATA_ERROR).
|
||||
//
|
||||
// @Summary Import MCP Servers
|
||||
// @Tags mcp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body handler.ImportMCPRequest true "import config"
|
||||
// @Router /api/v1/mcp/servers/import [post]
|
||||
func (h *MCPHandler) ImportMCPServers(c *gin.Context) {
|
||||
user, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
// Read the raw body so we can distinguish "key absent" from "key
|
||||
// present but empty" — the Python @validate_request("mcpServers")
|
||||
// decorator returns RetCode.ARGUMENT_ERROR for the former, while the
|
||||
// handler body returns RetCode.DATA_ERROR for the latter.
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "Invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
if len(body) > 0 {
|
||||
if err := json.Unmarshal(body, &raw); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "Invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
rawServers, hasServers := raw["mcpServers"]
|
||||
if !hasServers {
|
||||
// Match Python validate_request: code 101, message includes the
|
||||
// trailing "; " separator the Python decorator emits.
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeArgumentError,
|
||||
"data": nil,
|
||||
"message": "required argument are missing: mcpServers; ",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
var servers map[string]map[string]interface{}
|
||||
if err := json.Unmarshal(rawServers, &servers); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "Invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
if len(servers) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": nil, "message": "No MCP servers provided."})
|
||||
return
|
||||
}
|
||||
|
||||
var timeout float64
|
||||
if rawTimeout, ok := raw["timeout"]; ok {
|
||||
// Ignore parse errors for timeout to match Python's get_float
|
||||
// default-on-failure behavior; the service applies its own
|
||||
// 10 s fallback when timeout <= 0.
|
||||
_ = json.Unmarshal(rawTimeout, &timeout)
|
||||
}
|
||||
|
||||
results, err := h.mcpService.ImportServers(user.ID, servers, timeout)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"code": common.CodeServerError, "data": nil, "message": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": gin.H{"results": results}, "message": "success"})
|
||||
}
|
||||
|
||||
// TestMCPServer opens a live MCP session and returns the tools the server
|
||||
// advertises. The mcp_id path parameter identifies the stored record the
|
||||
// user is trying to validate; the actual connection uses the request body
|
||||
// so the user can preview unsaved edits — matching Python's test_mcp.
|
||||
//
|
||||
// @Summary Test MCP Server
|
||||
// @Tags mcp
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param mcp_id path string true "MCP server ID"
|
||||
// @Param request body service.TestServerRequest true "test parameters"
|
||||
// @Router /api/v1/mcp/servers/{mcp_id}/test [post]
|
||||
func (h *MCPHandler) TestMCPServer(c *gin.Context) {
|
||||
_, errorCode, errorMessage := GetUser(c)
|
||||
if errorCode != common.CodeSuccess {
|
||||
jsonError(c, errorCode, errorMessage)
|
||||
return
|
||||
}
|
||||
|
||||
mcpID := c.Param("mcp_id")
|
||||
if mcpID == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "mcp_id is required"})
|
||||
return
|
||||
}
|
||||
|
||||
var req service.TestServerRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"code": common.CodeBadRequest, "data": nil, "message": "Invalid request body: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// Mirror Python's @validate_request("url", "server_type"): missing
|
||||
// required fields → code 101 (ARGUMENT_ERROR), not code 102.
|
||||
var missingFields []string
|
||||
if req.URL == "" {
|
||||
missingFields = append(missingFields, "url")
|
||||
}
|
||||
if req.ServerType == "" {
|
||||
missingFields = append(missingFields, "server_type")
|
||||
}
|
||||
if len(missingFields) > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": common.CodeArgumentError,
|
||||
"data": nil,
|
||||
"message": "required argument are missing: " + strings.Join(missingFields, ", ") + "; ",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
tools, err := h.mcpService.TestServer(mcpID, &req)
|
||||
if mcpErrorResponse(c, err) {
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": tools, "message": "success"})
|
||||
}
|
||||
|
||||
func newMCPServerResponse(server *entity.MCPServer) *mcpServerResponse {
|
||||
if server == nil {
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user