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:
web-dev0521
2026-06-04 23:25:09 -06:00
committed by GitHub
parent 1d7e45115b
commit b8db200757
8 changed files with 1885 additions and 8 deletions

View File

@@ -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