feat(mcp): add MCP server management endpoints
- Introduced new endpoints for managing MCP servers, including GET, POST, PUT, and DELETE methods for server operations. - Organized routing under a new /mcp group to enhance endpoint management within the OpenAPI settings.
This commit is contained in:
parent
13e16c7099
commit
3b642fea78
3 changed files with 938 additions and 0 deletions
507
openapi/setting/mcp.go
Normal file
507
openapi/setting/mcp.go
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
package setting
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/mcp"
|
||||
mcpTypes "github.com/yaoapp/gou/mcp/types"
|
||||
gouTypes "github.com/yaoapp/gou/types"
|
||||
"github.com/yaoapp/yao/mcpclient"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
const mcpMaskPrefixLen = 7
|
||||
|
||||
func mcpOwner(info *oauthTypes.AuthorizedInfo) mcpclient.ClientOwner {
|
||||
if info.TeamID != "" {
|
||||
return mcpclient.ClientOwner{Type: "team", ID: info.TeamID}
|
||||
}
|
||||
return mcpclient.ClientOwner{Type: "user", ID: info.UserID}
|
||||
}
|
||||
|
||||
func mcpCheckOwnership(c *mcpclient.Client, info *oauthTypes.AuthorizedInfo) error {
|
||||
owner := mcpOwner(info)
|
||||
if c.Owner.Type != owner.Type || c.Owner.ID != owner.ID {
|
||||
return fmt.Errorf("server not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mcpMaskToken(token string) string {
|
||||
if token == "" {
|
||||
return ""
|
||||
}
|
||||
plain := cloudDecrypt(token)
|
||||
if len(plain) <= mcpMaskPrefixLen {
|
||||
return strings.Repeat("*", len(plain))
|
||||
}
|
||||
suffix := plain[len(plain)-4:]
|
||||
prefix := plain[:mcpMaskPrefixLen]
|
||||
return prefix + "..." + suffix
|
||||
}
|
||||
|
||||
func mcpClientToResponse(c *mcpclient.Client) map[string]interface{} {
|
||||
resp := map[string]interface{}{
|
||||
"id": c.ID,
|
||||
"name": c.Name,
|
||||
"label": c.Label,
|
||||
"transport": string(c.Transport),
|
||||
"url": c.URL,
|
||||
"enabled": c.Enabled,
|
||||
"status": c.Status,
|
||||
}
|
||||
if c.Description != "" {
|
||||
resp["description"] = c.Description
|
||||
}
|
||||
if c.AuthorizationToken != "" {
|
||||
resp["authorization_token"] = mcpMaskToken(c.AuthorizationToken)
|
||||
}
|
||||
if c.Timeout != "" {
|
||||
resp["timeout"] = c.Timeout
|
||||
}
|
||||
if len(c.Tags) > 0 {
|
||||
resp["tags"] = c.Tags
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// handleMCPList returns MCP servers for the current user/team.
|
||||
// Only http and sse transports are returned.
|
||||
// GET /setting/mcp/servers
|
||||
func handleMCPList(c *gin.Context) {
|
||||
info := authorized.GetInfo(c)
|
||||
owner := mcpOwner(info)
|
||||
|
||||
if mcpclient.Global == nil {
|
||||
respondError(c, http.StatusInternalServerError, "MCP client registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
all, err := mcpclient.Global.List(&mcpclient.ClientFilter{
|
||||
Owner: &owner,
|
||||
Source: mcpclient.ClientSourceAll,
|
||||
})
|
||||
if err != nil {
|
||||
all = []mcpclient.Client{}
|
||||
}
|
||||
|
||||
servers := make([]map[string]interface{}, 0, len(all))
|
||||
for i := range all {
|
||||
t := all[i].Transport
|
||||
if t != mcpTypes.TransportHTTP && t != mcpTypes.TransportSSE {
|
||||
continue
|
||||
}
|
||||
servers = append(servers, mcpClientToResponse(&all[i]))
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, map[string]interface{}{
|
||||
"servers": servers,
|
||||
})
|
||||
}
|
||||
|
||||
// handleMCPCreate creates a new MCP server.
|
||||
// POST /setting/mcp/servers
|
||||
func handleMCPCreate(c *gin.Context) {
|
||||
if !guardOwner(c) {
|
||||
return
|
||||
}
|
||||
info := authorized.GetInfo(c)
|
||||
|
||||
if mcpclient.Global == nil {
|
||||
respondError(c, http.StatusInternalServerError, "MCP client registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Transport string `json:"transport"`
|
||||
URL string `json:"url"`
|
||||
AuthorizationToken string `json:"authorization_token"`
|
||||
Timeout string `json:"timeout"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.Name == "" {
|
||||
respondError(c, http.StatusBadRequest, "name is required")
|
||||
return
|
||||
}
|
||||
if body.URL == "" {
|
||||
respondError(c, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
if _, err := url.ParseRequestURI(body.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid url format")
|
||||
return
|
||||
}
|
||||
|
||||
transport := mcpTypes.TransportHTTP
|
||||
if body.Transport == "sse" {
|
||||
transport = mcpTypes.TransportSSE
|
||||
}
|
||||
|
||||
owner := mcpOwner(info)
|
||||
|
||||
existing, _ := mcpclient.Global.List(&mcpclient.ClientFilter{
|
||||
Owner: &owner,
|
||||
Source: mcpclient.ClientSourceAll,
|
||||
})
|
||||
for _, ex := range existing {
|
||||
if strings.EqualFold(ex.Name, body.Name) {
|
||||
respondError(c, http.StatusBadRequest, fmt.Sprintf("server with name \"%s\" already exists", body.Name))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
clientID := owner.Type + "." + owner.ID + "." + body.Name
|
||||
client := &mcpclient.Client{
|
||||
ClientDSL: mcpTypes.ClientDSL{
|
||||
ID: clientID,
|
||||
Name: body.Name,
|
||||
Transport: transport,
|
||||
URL: body.URL,
|
||||
Timeout: body.Timeout,
|
||||
MetaInfo: gouTypes.MetaInfo{
|
||||
Label: body.Label,
|
||||
Description: body.Description,
|
||||
Tags: body.Tags,
|
||||
},
|
||||
},
|
||||
Enabled: true,
|
||||
Status: "unconfigured",
|
||||
Source: mcpclient.ClientSourceDynamic,
|
||||
Owner: owner,
|
||||
}
|
||||
|
||||
if body.AuthorizationToken != "" {
|
||||
client.AuthorizationToken = cloudEncrypt(body.AuthorizationToken)
|
||||
}
|
||||
if body.Timeout == "" {
|
||||
client.Timeout = "30s"
|
||||
}
|
||||
|
||||
token := body.AuthorizationToken
|
||||
status, _, errMsg := mcpProbeRaw(transport, body.URL, token, client.Timeout)
|
||||
if status != "connected" {
|
||||
respondError(c, http.StatusBadRequest, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
client.Status = "connected"
|
||||
created, err := mcpclient.Global.Create(client)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpClientToResponse(created))
|
||||
}
|
||||
|
||||
// handleMCPUpdate updates an existing MCP server.
|
||||
// PUT /setting/mcp/servers/:id
|
||||
func handleMCPUpdate(c *gin.Context) {
|
||||
if !guardOwner(c) {
|
||||
return
|
||||
}
|
||||
info := authorized.GetInfo(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if mcpclient.Global == nil {
|
||||
respondError(c, http.StatusInternalServerError, "MCP client registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := mcpclient.Global.Get(id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "server not found")
|
||||
return
|
||||
}
|
||||
if err := mcpCheckOwnership(existing, info); err != nil {
|
||||
respondError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Transport string `json:"transport"`
|
||||
URL string `json:"url"`
|
||||
AuthorizationToken string `json:"authorization_token"`
|
||||
Timeout string `json:"timeout"`
|
||||
Tags []string `json:"tags"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
if body.URL != "" {
|
||||
if _, err := url.ParseRequestURI(body.URL); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid url format")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated := &mcpclient.Client{
|
||||
ClientDSL: mcpTypes.ClientDSL{
|
||||
ID: id,
|
||||
Name: existing.Name,
|
||||
MetaInfo: gouTypes.MetaInfo{
|
||||
Label: existing.Label,
|
||||
Description: existing.Description,
|
||||
Tags: existing.Tags,
|
||||
},
|
||||
Transport: existing.Transport,
|
||||
URL: existing.URL,
|
||||
AuthorizationToken: existing.AuthorizationToken,
|
||||
Timeout: existing.Timeout,
|
||||
},
|
||||
Enabled: existing.Enabled,
|
||||
Status: existing.Status,
|
||||
Source: existing.Source,
|
||||
Owner: existing.Owner,
|
||||
}
|
||||
|
||||
if body.Name != "" {
|
||||
updated.Name = body.Name
|
||||
}
|
||||
if body.Label != "" {
|
||||
updated.Label = body.Label
|
||||
}
|
||||
if body.Description != "" {
|
||||
updated.Description = body.Description
|
||||
}
|
||||
if body.Transport != "" {
|
||||
if body.Transport == "sse" {
|
||||
updated.Transport = mcpTypes.TransportSSE
|
||||
} else {
|
||||
updated.Transport = mcpTypes.TransportHTTP
|
||||
}
|
||||
}
|
||||
if body.URL != "" {
|
||||
updated.URL = body.URL
|
||||
}
|
||||
if body.AuthorizationToken != "" {
|
||||
updated.AuthorizationToken = cloudEncrypt(body.AuthorizationToken)
|
||||
}
|
||||
if body.Timeout != "" {
|
||||
updated.Timeout = body.Timeout
|
||||
}
|
||||
if body.Tags != nil {
|
||||
updated.Tags = body.Tags
|
||||
}
|
||||
|
||||
token := body.AuthorizationToken
|
||||
if token == "" && updated.AuthorizationToken != "" {
|
||||
token = cloudDecrypt(updated.AuthorizationToken)
|
||||
}
|
||||
probeTransport := updated.Transport
|
||||
probeURL := updated.URL
|
||||
status, _, errMsg := mcpProbeRaw(probeTransport, probeURL, token, updated.Timeout)
|
||||
if status != "connected" {
|
||||
respondError(c, http.StatusBadRequest, errMsg)
|
||||
return
|
||||
}
|
||||
|
||||
updated.Status = "connected"
|
||||
result, err := mcpclient.Global.Update(id, updated)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpClientToResponse(result))
|
||||
}
|
||||
|
||||
// handleMCPDelete removes an MCP server.
|
||||
// DELETE /setting/mcp/servers/:id
|
||||
func handleMCPDelete(c *gin.Context) {
|
||||
if !guardOwner(c) {
|
||||
return
|
||||
}
|
||||
info := authorized.GetInfo(c)
|
||||
id := c.Param("id")
|
||||
|
||||
if mcpclient.Global == nil {
|
||||
respondError(c, http.StatusInternalServerError, "MCP client registry not initialized")
|
||||
return
|
||||
}
|
||||
|
||||
existing, err := mcpclient.Global.Get(id)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusNotFound, "server not found")
|
||||
return
|
||||
}
|
||||
if err := mcpCheckOwnership(existing, info); err != nil {
|
||||
respondError(c, http.StatusNotFound, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := mcpclient.Global.Delete(id); err != nil {
|
||||
respondError(c, http.StatusInternalServerError, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// mcpProbeRaw creates a temporary MCP client from raw config, tests Connect+Initialize+ListTools.
|
||||
func mcpProbeRaw(transport mcpTypes.TransportType, urlStr, token, timeout string) (status string, latencyMs int64, errMsg string) {
|
||||
if timeout == "" {
|
||||
timeout = "30s"
|
||||
}
|
||||
tempID := fmt.Sprintf("__probe_%d", time.Now().UnixNano())
|
||||
dsl := mcpTypes.ClientDSL{
|
||||
ID: tempID,
|
||||
Name: tempID,
|
||||
Transport: transport,
|
||||
URL: urlStr,
|
||||
AuthorizationToken: token,
|
||||
Timeout: timeout,
|
||||
}
|
||||
dslJSON, err := json.Marshal(dsl)
|
||||
if err != nil {
|
||||
return "disconnected", 0, fmt.Sprintf("marshal: %s", err)
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
mcpClient, err := mcp.LoadClientSourceWithType(string(dslJSON), tempID, "")
|
||||
if err != nil {
|
||||
return "disconnected", 0, fmt.Sprintf("load: %s", err)
|
||||
}
|
||||
defer mcp.UnloadClient(tempID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := mcpClient.Connect(ctx); err != nil {
|
||||
return "disconnected", time.Since(start).Milliseconds(), fmt.Sprintf("connect: %s", err)
|
||||
}
|
||||
defer mcpClient.Disconnect(context.Background())
|
||||
|
||||
if _, err := mcpClient.Initialize(ctx); err != nil {
|
||||
return "disconnected", time.Since(start).Milliseconds(), fmt.Sprintf("initialize: %s", err)
|
||||
}
|
||||
|
||||
_, err = mcpClient.ListTools(ctx, "")
|
||||
latencyMs = time.Since(start).Milliseconds()
|
||||
if err != nil {
|
||||
return "disconnected", latencyMs, fmt.Sprintf("listTools: %s", err)
|
||||
}
|
||||
return "connected", latencyMs, ""
|
||||
}
|
||||
|
||||
// handleMCPTest tests connectivity using raw config (for add/edit before save).
|
||||
// Creates a temporary runtime client, tests ListTools, then cleans up.
|
||||
// POST /setting/mcp/test
|
||||
func handleMCPTest(c *gin.Context) {
|
||||
if !guardOwner(c) {
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Transport string `json:"transport"`
|
||||
URL string `json:"url"`
|
||||
AuthorizationToken string `json:"authorization_token"`
|
||||
Timeout string `json:"timeout"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
respondError(c, http.StatusBadRequest, "invalid request body")
|
||||
return
|
||||
}
|
||||
if body.URL == "" {
|
||||
respondError(c, http.StatusBadRequest, "url is required")
|
||||
return
|
||||
}
|
||||
|
||||
transport := mcpTypes.TransportHTTP
|
||||
if body.Transport == "sse" {
|
||||
transport = mcpTypes.TransportSSE
|
||||
}
|
||||
timeout := body.Timeout
|
||||
if timeout == "" {
|
||||
timeout = "30s"
|
||||
}
|
||||
|
||||
tempID := fmt.Sprintf("__test_%d", time.Now().UnixNano())
|
||||
dsl := mcpTypes.ClientDSL{
|
||||
ID: tempID,
|
||||
Name: tempID,
|
||||
Transport: transport,
|
||||
URL: body.URL,
|
||||
AuthorizationToken: body.AuthorizationToken,
|
||||
Timeout: timeout,
|
||||
}
|
||||
|
||||
dslJSON, err := json.Marshal(dsl)
|
||||
if err != nil {
|
||||
respondError(c, http.StatusInternalServerError, "failed to marshal config")
|
||||
return
|
||||
}
|
||||
|
||||
start := time.Now()
|
||||
mcpClient, err := mcp.LoadClientSourceWithType(string(dslJSON), tempID, "")
|
||||
if err != nil {
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpclient.ClientTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Failed to load client: %s", err.Error()),
|
||||
})
|
||||
return
|
||||
}
|
||||
defer mcp.UnloadClient(tempID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := mcpClient.Connect(ctx); err != nil {
|
||||
latencyMs := time.Since(start).Milliseconds()
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpclient.ClientTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Connection failed: %s", err.Error()),
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
return
|
||||
}
|
||||
defer mcpClient.Disconnect(context.Background())
|
||||
|
||||
if _, err := mcpClient.Initialize(ctx); err != nil {
|
||||
latencyMs := time.Since(start).Milliseconds()
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpclient.ClientTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Initialization failed: %s", err.Error()),
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
_, err = mcpClient.ListTools(ctx, "")
|
||||
latencyMs := time.Since(start).Milliseconds()
|
||||
|
||||
if err != nil {
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpclient.ClientTestResult{
|
||||
Success: false,
|
||||
Message: fmt.Sprintf("Connection failed: %s", err.Error()),
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
response.RespondWithSuccess(c, http.StatusOK, mcpclient.ClientTestResult{
|
||||
Success: true,
|
||||
Message: "Connection successful",
|
||||
LatencyMs: latencyMs,
|
||||
})
|
||||
}
|
||||
|
|
@ -58,6 +58,13 @@ func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
|
|||
smtpG.PUT("", handleSmtpUpdate)
|
||||
smtpG.PUT("/toggle", handleSmtpToggle)
|
||||
smtpG.POST("/test", handleSmtpTest)
|
||||
|
||||
mcpG := group.Group("/mcp")
|
||||
mcpG.GET("/servers", handleMCPList)
|
||||
mcpG.POST("/servers", handleMCPCreate)
|
||||
mcpG.PUT("/servers/:id", handleMCPUpdate)
|
||||
mcpG.DELETE("/servers/:id", handleMCPDelete)
|
||||
mcpG.POST("/test", handleMCPTest)
|
||||
}
|
||||
|
||||
// requireOwner checks that the current user is the team owner.
|
||||
|
|
|
|||
424
openapi/tests/setting/mcp_test.go
Normal file
424
openapi/tests/setting/mcp_test.go
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
package setting_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
mcpTypes "github.com/yaoapp/gou/mcp/types"
|
||||
gouTypes "github.com/yaoapp/gou/types"
|
||||
"github.com/yaoapp/yao/mcpclient"
|
||||
"github.com/yaoapp/yao/openapi/tests/testutils"
|
||||
)
|
||||
|
||||
func initMcpClientRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
if mcpclient.Global == nil {
|
||||
if err := mcpclient.Init(); err != nil {
|
||||
t.Fatalf("mcpclient.Init: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func obtainTokenInfo(t *testing.T, serverURL string) *testutils.TokenInfo {
|
||||
t.Helper()
|
||||
client := testutils.RegisterTestClient(t, "MCP Test", []string{"https://localhost/callback"})
|
||||
t.Cleanup(func() { testutils.CleanupTestClient(t, client.ClientID) })
|
||||
return testutils.ObtainAccessToken(t, serverURL, client.ClientID, client.ClientSecret, "https://localhost/callback", "openid profile")
|
||||
}
|
||||
|
||||
func seedMCPServer(t *testing.T, ownerID, name, url string) string {
|
||||
t.Helper()
|
||||
clientID := "user." + ownerID + "." + name
|
||||
client := &mcpclient.Client{
|
||||
ClientDSL: mcpTypes.ClientDSL{
|
||||
ID: clientID,
|
||||
Name: name,
|
||||
Transport: mcpTypes.TransportHTTP,
|
||||
URL: url,
|
||||
Timeout: "30s",
|
||||
MetaInfo: gouTypes.MetaInfo{Label: name},
|
||||
},
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Source: mcpclient.ClientSourceDynamic,
|
||||
Owner: mcpclient.ClientOwner{Type: "user", ID: ownerID},
|
||||
}
|
||||
_, err := mcpclient.Global.Create(client)
|
||||
if err != nil && !strings.Contains(err.Error(), "already exists") {
|
||||
t.Fatalf("seedMCPServer: %v", err)
|
||||
}
|
||||
return clientID
|
||||
}
|
||||
|
||||
// startMockMCPServer starts a minimal MCP-compatible HTTP server for testing.
|
||||
// Handles JSON-RPC: initialize, notifications/initialized, tools/list.
|
||||
func startMockMCPServer(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
defer r.Body.Close()
|
||||
|
||||
var req struct {
|
||||
JSONRPC string `json:"jsonrpc"`
|
||||
ID interface{} `json:"id,omitempty"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
json.Unmarshal(body, &req)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"protocolVersion": "2025-03-26",
|
||||
"serverInfo": map[string]interface{}{"name": "mock-mcp", "version": "1.0.0"},
|
||||
"capabilities": map[string]interface{}{"tools": map[string]interface{}{}},
|
||||
},
|
||||
})
|
||||
case "notifications/initialized":
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
case "tools/list":
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"result": map[string]interface{}{
|
||||
"tools": []interface{}{
|
||||
map[string]interface{}{
|
||||
"name": "echo",
|
||||
"description": "Echo tool",
|
||||
"inputSchema": map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||
"jsonrpc": "2.0",
|
||||
"id": req.ID,
|
||||
"error": map[string]interface{}{"code": -32601, "message": "method not found"},
|
||||
})
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
func TestMCPListServers(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
token := obtainToken(t, serverURL)
|
||||
|
||||
req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if !assert.NoError(t, err) || !assert.NotNil(t, resp) {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
assert.Contains(t, body, "servers")
|
||||
servers, ok := body["servers"].([]interface{})
|
||||
assert.True(t, ok)
|
||||
t.Logf("Listed %d MCP servers", len(servers))
|
||||
}
|
||||
|
||||
func TestMCPListUnauthenticated(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
|
||||
req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestMCPCreateServer(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
mockMCP := startMockMCPServer(t)
|
||||
defer mockMCP.Close()
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "test-create",
|
||||
"label": "Test Create",
|
||||
"transport": "http",
|
||||
"url": mockMCP.URL,
|
||||
"timeout": "10s",
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL()+"/setting/mcp/servers", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
createdID, _ := body["id"].(string)
|
||||
assert.NotEmpty(t, createdID)
|
||||
assert.Equal(t, "test-create", body["name"])
|
||||
assert.Equal(t, "Test Create", body["label"])
|
||||
assert.Equal(t, "connected", body["status"])
|
||||
t.Logf("Created server: %s", createdID)
|
||||
|
||||
// Verify in list
|
||||
listReq, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
listResp, _ := http.DefaultClient.Do(listReq)
|
||||
var listBody map[string]interface{}
|
||||
json.NewDecoder(listResp.Body).Decode(&listBody)
|
||||
listResp.Body.Close()
|
||||
|
||||
found := false
|
||||
for _, s := range listBody["servers"].([]interface{}) {
|
||||
if s.(map[string]interface{})["id"] == createdID {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
assert.True(t, found, "created server should appear in list")
|
||||
|
||||
// Cleanup
|
||||
mcpclient.Global.Delete(createdID)
|
||||
}
|
||||
|
||||
func TestMCPCreateRejectsUnreachable(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
token := obtainToken(t, serverURL)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "unreachable",
|
||||
"label": "Unreachable",
|
||||
"transport": "http",
|
||||
"url": "https://192.0.2.1/mcp",
|
||||
"timeout": "3s",
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL()+"/setting/mcp/servers", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "create should reject unreachable URL")
|
||||
}
|
||||
|
||||
func TestMCPDuplicateName(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
clientID := seedMCPServer(t, ti.UserID, "dup-test", "https://example.com/mcp")
|
||||
defer mcpclient.Global.Delete(clientID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "dup-test",
|
||||
"label": "Duplicate",
|
||||
"transport": "http",
|
||||
"url": "https://example.com/mcp",
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("POST", serverURL+baseURL()+"/setting/mcp/servers", bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
||||
}
|
||||
|
||||
func TestMCPUpdateServer(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
mockMCP := startMockMCPServer(t)
|
||||
defer mockMCP.Close()
|
||||
|
||||
clientID := seedMCPServer(t, ti.UserID, "upd-test", "https://example.com/mcp")
|
||||
defer mcpclient.Global.Delete(clientID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"label": "Updated Label",
|
||||
"url": mockMCP.URL,
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/mcp/servers/"+clientID, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("Update response (%d): %s", resp.StatusCode, string(respBody))
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var body map[string]interface{}
|
||||
json.Unmarshal(respBody, &body)
|
||||
assert.Equal(t, "Updated Label", body["label"])
|
||||
assert.Equal(t, mockMCP.URL, body["url"])
|
||||
assert.Equal(t, "connected", body["status"])
|
||||
}
|
||||
|
||||
func TestMCPUpdateRejectsUnreachable(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
clientID := seedMCPServer(t, ti.UserID, "upd-fail", "https://example.com/mcp")
|
||||
defer mcpclient.Global.Delete(clientID)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"url": "https://192.0.2.1/mcp",
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, _ := http.NewRequest("PUT", serverURL+baseURL()+"/setting/mcp/servers/"+clientID, bytes.NewReader(raw))
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode, "update should reject unreachable URL")
|
||||
}
|
||||
|
||||
func TestMCPTokenMasking(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
clientID := "user." + ti.UserID + ".mask-test"
|
||||
client := &mcpclient.Client{
|
||||
ClientDSL: mcpTypes.ClientDSL{
|
||||
ID: clientID,
|
||||
Name: "mask-test",
|
||||
Transport: mcpTypes.TransportHTTP,
|
||||
URL: "https://example.com/mcp",
|
||||
AuthorizationToken: "Bearer sk-test-token-12345678",
|
||||
Timeout: "30s",
|
||||
MetaInfo: gouTypes.MetaInfo{Label: "Mask Test"},
|
||||
},
|
||||
Enabled: true,
|
||||
Status: "connected",
|
||||
Source: mcpclient.ClientSourceDynamic,
|
||||
Owner: mcpclient.ClientOwner{Type: "user", ID: ti.UserID},
|
||||
}
|
||||
mcpclient.Global.Create(client)
|
||||
defer mcpclient.Global.Delete(clientID)
|
||||
|
||||
req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
resp, _ := http.DefaultClient.Do(req)
|
||||
var body map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&body)
|
||||
resp.Body.Close()
|
||||
|
||||
for _, s := range body["servers"].([]interface{}) {
|
||||
sm := s.(map[string]interface{})
|
||||
if sm["id"] == clientID {
|
||||
maskedToken, _ := sm["authorization_token"].(string)
|
||||
assert.True(t, strings.Contains(maskedToken, "..."), "token should be masked, got: %s", maskedToken)
|
||||
assert.NotEqual(t, "Bearer sk-test-token-12345678", maskedToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPDeleteServer(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
ti := obtainTokenInfo(t, serverURL)
|
||||
|
||||
clientID := seedMCPServer(t, ti.UserID, "del-test", "https://example.com/mcp")
|
||||
|
||||
req, _ := http.NewRequest("DELETE", serverURL+baseURL()+"/setting/mcp/servers/"+clientID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||
|
||||
listReq, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
listReq.Header.Set("Authorization", "Bearer "+ti.AccessToken)
|
||||
listResp, _ := http.DefaultClient.Do(listReq)
|
||||
var listBody map[string]interface{}
|
||||
json.NewDecoder(listResp.Body).Decode(&listBody)
|
||||
listResp.Body.Close()
|
||||
|
||||
for _, s := range listBody["servers"].([]interface{}) {
|
||||
sm := s.(map[string]interface{})
|
||||
assert.NotEqual(t, clientID, sm["id"], "deleted server should not appear in list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMCPACL_ReadOnlyScopeCannotWrite(t *testing.T) {
|
||||
serverURL := testutils.Prepare(t)
|
||||
defer testutils.Clean()
|
||||
initSettingRegistry(t)
|
||||
initMcpClientRegistry(t)
|
||||
|
||||
readToken := obtainRestrictedToken(t, serverURL, "setting:mcp:read:all")
|
||||
|
||||
req, _ := http.NewRequest("GET", serverURL+baseURL()+"/setting/mcp/servers", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+readToken)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
assert.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
payload := map[string]interface{}{
|
||||
"name": "acl-test", "label": "ACL Test", "transport": "http", "url": "https://example.com/mcp",
|
||||
}
|
||||
raw, _ := json.Marshal(payload)
|
||||
req2, _ := http.NewRequest("POST", serverURL+baseURL()+"/setting/mcp/servers", bytes.NewReader(raw))
|
||||
req2.Header.Set("Authorization", "Bearer "+readToken)
|
||||
req2.Header.Set("Content-Type", "application/json")
|
||||
resp2, err := http.DefaultClient.Do(req2)
|
||||
assert.NoError(t, err)
|
||||
defer resp2.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp2.StatusCode)
|
||||
|
||||
req3, _ := http.NewRequest("DELETE", serverURL+baseURL()+"/setting/mcp/servers/some-id", nil)
|
||||
req3.Header.Set("Authorization", "Bearer "+readToken)
|
||||
resp3, err := http.DefaultClient.Do(req3)
|
||||
assert.NoError(t, err)
|
||||
defer resp3.Body.Close()
|
||||
assert.Equal(t, http.StatusForbidden, resp3.StatusCode)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue