380 lines
11 KiB
Go
380 lines
11 KiB
Go
package extensions_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/discovery"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/test/mock_mcp"
|
|
)
|
|
|
|
func TestDiscoveryPreservesCLIMetadataAndLifecycle(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := newRuntimeMarketServer(t, mockmcp.DefaultFixture())
|
|
defer srv.Close()
|
|
|
|
service := discovery.NewService(
|
|
market.NewClient(srv.URL, srv.Client()),
|
|
transport.NewClient(srv.Client()),
|
|
cache.NewStore(t.TempDir()),
|
|
)
|
|
|
|
servers, err := service.DiscoverServers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("DiscoverServers() error = %v", err)
|
|
}
|
|
if len(servers) != 2 {
|
|
t.Fatalf("DiscoverServers() len = %d, want 2", len(servers))
|
|
}
|
|
|
|
doc := findServerByName(t, servers, "钉钉文档")
|
|
if !doc.HasCLIMeta {
|
|
t.Fatalf("doc server should expose CLI metadata")
|
|
}
|
|
if doc.CLI.ID != "doc" || doc.CLI.Command != "doc" {
|
|
t.Fatalf("doc CLI overlay = %#v, want doc command", doc.CLI)
|
|
}
|
|
if len(doc.CLI.Tools) != 2 {
|
|
t.Fatalf("doc CLI tools len = %d, want 2", len(doc.CLI.Tools))
|
|
}
|
|
if doc.DetailLocator.MCPID != 9629 {
|
|
t.Fatalf("doc detail locator mcpId = %d, want 9629", doc.DetailLocator.MCPID)
|
|
}
|
|
if doc.Lifecycle.DeprecatedBy != 0 {
|
|
t.Fatalf("doc lifecycle = %#v, want zero-value lifecycle", doc.Lifecycle)
|
|
}
|
|
|
|
legacy := findServerByName(t, servers, "钉钉文档(旧)")
|
|
if !legacy.HasCLIMeta {
|
|
t.Fatalf("legacy server should also expose CLI metadata")
|
|
}
|
|
if legacy.Lifecycle.DeprecatedBy != 9629 {
|
|
t.Fatalf("legacy lifecycle deprecatedBy = %d, want 9629", legacy.Lifecycle.DeprecatedBy)
|
|
}
|
|
if legacy.Lifecycle.MigrationURL == "" {
|
|
t.Fatalf("legacy lifecycle should carry migration URL")
|
|
}
|
|
}
|
|
|
|
func TestDiscoverySurfacesNewServerWithoutRuntimeChanges(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fixture := mockmcp.DefaultFixture()
|
|
fixture.Servers = append(fixture.Servers, mockmcp.ServerFixture{
|
|
Name: "钉钉会议",
|
|
Description: "会议能力通过市场元数据零代码出现",
|
|
SchemaURI: mockmcp.DefaultSchemaURI,
|
|
RemotePath: "/server/meeting",
|
|
Registry: market.RegistryMetadata{
|
|
IsLatest: true,
|
|
PublishedAt: "2026-03-19T00:00:00Z",
|
|
UpdatedAt: "2026-03-20T00:00:00Z",
|
|
Status: "active",
|
|
MCPID: 9901,
|
|
DetailURL: "",
|
|
Quality: market.QualityMetadata{
|
|
HighQuality: true,
|
|
Official: true,
|
|
DTBiz: true,
|
|
},
|
|
},
|
|
CLI: market.CLIOverlay{
|
|
ID: "meeting",
|
|
Command: "meeting",
|
|
Description: "会议管理",
|
|
Prefixes: []string{"meeting"},
|
|
Aliases: []string{"钉钉会议"},
|
|
Tools: []market.CLITool{
|
|
{
|
|
Name: "create_meeting",
|
|
CLIName: "create-meeting",
|
|
Title: "创建会议",
|
|
Description: "创建新的钉钉会议",
|
|
Category: "写入",
|
|
},
|
|
},
|
|
},
|
|
})
|
|
|
|
srv := newRuntimeMarketServer(t, fixture)
|
|
defer srv.Close()
|
|
|
|
service := discovery.NewService(
|
|
market.NewClient(srv.URL, srv.Client()),
|
|
transport.NewClient(srv.Client()),
|
|
cache.NewStore(t.TempDir()),
|
|
)
|
|
|
|
servers, err := service.DiscoverServers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("DiscoverServers() error = %v", err)
|
|
}
|
|
|
|
meeting := findServerByName(t, servers, "钉钉会议")
|
|
if meeting.Endpoint == "" {
|
|
t.Fatalf("meeting server missing endpoint: %#v", meeting)
|
|
}
|
|
if !meeting.HasCLIMeta {
|
|
t.Fatalf("meeting server should expose CLI metadata")
|
|
}
|
|
if meeting.CLI.ID != "meeting" || meeting.CLI.Command != "meeting" {
|
|
t.Fatalf("meeting CLI overlay = %#v, want meeting command", meeting.CLI)
|
|
}
|
|
if meeting.Source != "live_market" {
|
|
t.Fatalf("meeting source = %q, want live_market", meeting.Source)
|
|
}
|
|
}
|
|
|
|
func TestDiscoverDetailFallsBackToCachedSnapshot(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
fixture := mockmcp.DefaultFixture()
|
|
for idx := range fixture.Servers {
|
|
if fixture.Servers[idx].Name == "钉钉文档" {
|
|
fixture.Servers[idx].Detail = nil
|
|
}
|
|
}
|
|
|
|
srv := newRuntimeMarketServer(t, fixture)
|
|
defer srv.Close()
|
|
|
|
store := cache.NewStore(t.TempDir())
|
|
service := discovery.NewService(
|
|
market.NewClient(srv.URL, srv.Client()),
|
|
transport.NewClient(srv.Client()),
|
|
store,
|
|
)
|
|
|
|
servers, err := service.DiscoverServers(context.Background())
|
|
if err != nil {
|
|
t.Fatalf("DiscoverServers() error = %v", err)
|
|
}
|
|
doc := findServerByName(t, servers, "钉钉文档")
|
|
|
|
cached := market.DetailResponse{
|
|
Success: true,
|
|
Result: market.DetailResult{
|
|
MCPID: doc.DetailLocator.MCPID,
|
|
Name: doc.DisplayName,
|
|
Description: doc.Description,
|
|
Tools: []market.DetailTool{
|
|
{
|
|
ToolName: "search_documents",
|
|
ToolTitle: "搜索文档",
|
|
ToolDesc: "缓存回退文档搜索",
|
|
IsSensitive: false,
|
|
ToolRequest: `{"type":"object","properties":{"keyword":{"type":"string"}}}`,
|
|
ToolResponse: `{"type":"object"}`,
|
|
ActionVersion: "cached-version",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
raw, err := json.Marshal(cached)
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal() error = %v", err)
|
|
}
|
|
if err := store.SaveDetail("default/default", doc.Key, cache.DetailSnapshot{
|
|
MCPID: doc.DetailLocator.MCPID,
|
|
Payload: raw,
|
|
}); err != nil {
|
|
t.Fatalf("SaveDetail() error = %v", err)
|
|
}
|
|
|
|
detail, err := service.DiscoverDetail(context.Background(), doc)
|
|
if err != nil {
|
|
t.Fatalf("DiscoverDetail() error = %v", err)
|
|
}
|
|
if !detail.Success {
|
|
t.Fatalf("DiscoverDetail() success = false, want cached success")
|
|
}
|
|
if detail.Result.Tools[0].ActionVersion != "cached-version" {
|
|
t.Fatalf("DiscoverDetail() returned %q, want cached-version", detail.Result.Tools[0].ActionVersion)
|
|
}
|
|
}
|
|
|
|
func TestToolsCallUsesMockTransportPath(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
srv := mockmcp.DefaultServer()
|
|
defer srv.Close()
|
|
|
|
client := transport.NewClient(srv.Client())
|
|
result, err := client.CallTool(context.Background(), srv.RemoteURL("/server/doc"), "create_document", map[string]any{
|
|
"title": "Quarterly Report",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("CallTool() error = %v", err)
|
|
}
|
|
if got := result.Content["documentId"]; got != "doc-123" {
|
|
t.Fatalf("CallTool() documentId = %#v, want doc-123", got)
|
|
}
|
|
}
|
|
|
|
func findServerByName(t *testing.T, servers []market.ServerDescriptor, name string) market.ServerDescriptor {
|
|
t.Helper()
|
|
|
|
for _, server := range servers {
|
|
if server.DisplayName == name {
|
|
return server
|
|
}
|
|
}
|
|
t.Fatalf("server %q not found in %#v", name, servers)
|
|
return market.ServerDescriptor{}
|
|
}
|
|
|
|
func newRuntimeMarketServer(t *testing.T, fixture mockmcp.Fixture) *httptest.Server {
|
|
t.Helper()
|
|
|
|
mux := http.NewServeMux()
|
|
srv := httptest.NewServer(mux)
|
|
|
|
mux.HandleFunc("/cli/discovery/apis", func(w http.ResponseWriter, r *http.Request) {
|
|
response := market.ListResponse{}
|
|
for _, server := range fixture.Servers {
|
|
response.Servers = append(response.Servers, market.ServerEnvelope{
|
|
Server: market.RegistryServer{
|
|
SchemaURI: server.SchemaURI,
|
|
Name: server.Name,
|
|
Description: server.Description,
|
|
Remotes: []market.RegistryRemote{
|
|
{Type: "streamable-http", URL: srv.URL + server.RemotePath},
|
|
},
|
|
},
|
|
Meta: market.EnvelopeMeta{
|
|
Registry: server.RegistryWithDetailURL(srv.URL + "/mcp/market/detail?mcpId=" + strconv.Itoa(server.Registry.MCPID)),
|
|
CLI: server.CLI,
|
|
},
|
|
})
|
|
}
|
|
response.Metadata.Count = len(response.Servers)
|
|
if err := json.NewEncoder(w).Encode(response); err != nil {
|
|
t.Fatalf("encode market response: %v", err)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/mcp/market/detail", func(w http.ResponseWriter, r *http.Request) {
|
|
raw := r.URL.Query().Get("mcpId")
|
|
mcpID, err := strconv.Atoi(raw)
|
|
if err != nil {
|
|
http.Error(w, "invalid mcpId", http.StatusBadRequest)
|
|
return
|
|
}
|
|
|
|
server, ok := fixture.ServerByMCPID(mcpID)
|
|
if !ok || server.Detail == nil {
|
|
http.Error(w, "detail fixture missing", http.StatusNotFound)
|
|
return
|
|
}
|
|
if err := json.NewEncoder(w).Encode(server.Detail.Response); err != nil {
|
|
t.Fatalf("encode detail response: %v", err)
|
|
}
|
|
})
|
|
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
server, ok := fixture.ServerByRemotePath(r.URL.Path)
|
|
if !ok {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
|
|
var req map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
http.Error(w, "invalid json-rpc request", http.StatusBadRequest)
|
|
return
|
|
}
|
|
method, _ := req["method"].(string)
|
|
switch method {
|
|
case "initialize":
|
|
protocolVersion := mockmcp.DefaultProtocolVer
|
|
capabilities := map[string]any{"tools": map[string]any{"listChanged": false}}
|
|
serverInfo := map[string]any{"name": server.Name, "version": "0.0.0"}
|
|
if server.MCP != nil {
|
|
if server.MCP.ProtocolVersion != "" {
|
|
protocolVersion = server.MCP.ProtocolVersion
|
|
}
|
|
if len(server.MCP.Capabilities) > 0 {
|
|
capabilities = server.MCP.Capabilities
|
|
}
|
|
if len(server.MCP.ServerInfo) > 0 {
|
|
serverInfo = server.MCP.ServerInfo
|
|
}
|
|
}
|
|
payload := map[string]any{
|
|
"protocolVersion": protocolVersion,
|
|
"capabilities": capabilities,
|
|
"serverInfo": serverInfo,
|
|
}
|
|
writeJSONRPC(w, req, payload, nil)
|
|
case "notifications/initialized":
|
|
w.WriteHeader(http.StatusNoContent)
|
|
case "tools/list":
|
|
tools := []any(nil)
|
|
if server.MCP != nil && len(server.MCP.Tools) > 0 {
|
|
tools = make([]any, 0, len(server.MCP.Tools))
|
|
for _, tool := range server.MCP.Tools {
|
|
tools = append(tools, tool)
|
|
}
|
|
}
|
|
writeJSONRPC(w, req, map[string]any{"tools": tools}, nil)
|
|
case "tools/call":
|
|
toolName := ""
|
|
if params, ok := req["params"].(map[string]any); ok {
|
|
if name, ok := params["name"].(string); ok {
|
|
toolName = name
|
|
}
|
|
}
|
|
call := mockmcp.ToolCallFixture{}
|
|
if server.MCP != nil && server.MCP.Calls != nil {
|
|
if fixtureCall, ok := server.MCP.Calls[toolName]; ok {
|
|
call = fixtureCall
|
|
}
|
|
}
|
|
if call.Error != nil {
|
|
writeJSONRPC(w, req, nil, call.Error)
|
|
return
|
|
}
|
|
if call.Status != 0 {
|
|
w.WriteHeader(call.Status)
|
|
}
|
|
writeJSONRPC(w, req, call.ResultOrDefault(toolName), nil)
|
|
default:
|
|
http.Error(w, "unexpected json-rpc method", http.StatusBadRequest)
|
|
}
|
|
})
|
|
|
|
return srv
|
|
}
|
|
|
|
func writeJSONRPC(w http.ResponseWriter, req map[string]any, result any, rpcErr *transport.RPCError) {
|
|
payload := map[string]any{
|
|
"jsonrpc": "2.0",
|
|
"id": requestID(req),
|
|
}
|
|
if rpcErr != nil {
|
|
payload["error"] = rpcErr
|
|
} else {
|
|
payload["result"] = result
|
|
}
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
func requestID(req map[string]any) any {
|
|
if id, ok := req["id"]; ok {
|
|
return id
|
|
}
|
|
return 0
|
|
}
|