- 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.
424 lines
13 KiB
Go
424 lines
13 KiB
Go
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)
|
|
}
|