From 3b642fea78d55b9961dfdd3ed802edc421b3261c Mon Sep 17 00:00:00 2001 From: Max Date: Wed, 29 Apr 2026 14:53:00 +0800 Subject: [PATCH] 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. --- openapi/setting/mcp.go | 507 ++++++++++++++++++++++++++++++ openapi/setting/setting.go | 7 + openapi/tests/setting/mcp_test.go | 424 +++++++++++++++++++++++++ 3 files changed, 938 insertions(+) create mode 100644 openapi/setting/mcp.go create mode 100644 openapi/tests/setting/mcp_test.go diff --git a/openapi/setting/mcp.go b/openapi/setting/mcp.go new file mode 100644 index 00000000..490e005a --- /dev/null +++ b/openapi/setting/mcp.go @@ -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, + }) +} diff --git a/openapi/setting/setting.go b/openapi/setting/setting.go index 3675065a..a385eae9 100644 --- a/openapi/setting/setting.go +++ b/openapi/setting/setting.go @@ -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. diff --git a/openapi/tests/setting/mcp_test.go b/openapi/tests/setting/mcp_test.go new file mode 100644 index 00000000..d1bbf6ea --- /dev/null +++ b/openapi/tests/setting/mcp_test.go @@ -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) +}