Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ea8bad074 | ||
|
|
7b7aeadbbe | ||
|
|
94ad422a9f | ||
|
|
93318f4a83 | ||
|
|
a14fd0250c | ||
|
|
4bf300d862 | ||
|
|
1a1fc531f5 | ||
|
|
9fc570607f | ||
|
|
4851d19141 | ||
|
|
42fb25d150 | ||
|
|
416ad6571d | ||
|
|
c5decb2f90 |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.8%"><title>coverage: 48.8%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.8%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.8%</text></g></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="108" height="20" role="img" aria-label="coverage: 48.7%"><title>coverage: 48.7%</title><linearGradient id="s" x2="0" y2="100%"><stop offset="0" stop-color="#bbb" stop-opacity=".1"/><stop offset="1" stop-opacity=".1"/></linearGradient><clipPath id="r"><rect width="108" height="20" rx="3" fill="#fff"/></clipPath><g clip-path="url(#r)"><rect width="61" height="20" fill="#555"/><rect x="61" width="47" height="20" fill="#e05d44"/><rect width="108" height="20" fill="url(#s)"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text aria-hidden="true" x="315" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="510">coverage</text><text x="315" y="140" transform="scale(.1)" fill="#fff" textLength="510">coverage</text><text aria-hidden="true" x="835" y="150" fill="#010101" fill-opacity=".3" transform="scale(.1)" textLength="370">48.7%</text><text x="835" y="140" transform="scale(.1)" fill="#fff" textLength="370">48.7%</text></g></svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
@@ -190,6 +190,8 @@ func newPluginRemoveCommand() *cobra.Command {
|
||||
Args: cobra.ExactArgs(1),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Stop stdio clients before removing to release file locks
|
||||
StopStdioClientsByPlugin(args[0])
|
||||
keepData, _ := cmd.Flags().GetBool("keep-data")
|
||||
loader := plugin.NewLoader(RawVersion())
|
||||
if err := loader.RemovePlugin(args[0], keepData); err != nil {
|
||||
|
||||
+30
-5
@@ -57,9 +57,17 @@ type outputFileContextKey struct{}
|
||||
const recoveryEventStderrPrefix = "RECOVERY_EVENT_ID="
|
||||
|
||||
// Execute runs the root command and returns the process exit code.
|
||||
func Execute() int {
|
||||
func Execute() (exitCode int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: internal panic: %v\n", r)
|
||||
exitCode = 5
|
||||
}
|
||||
}()
|
||||
|
||||
timing := NewTimingCollector()
|
||||
defer func() {
|
||||
StopAllStdioClients() // Ensure child processes are terminated on exit
|
||||
timing.PrintIfEnabled()
|
||||
timing.WriteReportIfEnabled(RawVersion(), SanitizeCommand(os.Args))
|
||||
}()
|
||||
@@ -265,6 +273,7 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine)
|
||||
return nil
|
||||
},
|
||||
PersistentPostRunE: func(cmd *cobra.Command, args []string) error {
|
||||
StopAllStdioClients()
|
||||
CloseFileLogger()
|
||||
return closeOutputSink(cmd)
|
||||
},
|
||||
@@ -1029,8 +1038,23 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
|
||||
// 0a. Ensure default managed plugins are installed (first-run bootstrap).
|
||||
updater := plugin.NewUpdater(pluginLoader.PluginsDir, RawVersion())
|
||||
accessToken, tokenErr := loadSkillAccessToken()
|
||||
if tokenErr == nil && accessToken != "" {
|
||||
// Load TokenData once; reuse for plugin bootstrap, updates, and stdio injection.
|
||||
tokenData, _ := authpkg.LoadTokenData(defaultConfigDir())
|
||||
var userCtx *plugin.UserContext
|
||||
if tokenData != nil {
|
||||
// Inject user context if either UserID or CorpID is present.
|
||||
if tokenData.UserID != "" || tokenData.CorpID != "" {
|
||||
userCtx = &plugin.UserContext{
|
||||
UserID: tokenData.UserID,
|
||||
CorpID: tokenData.CorpID,
|
||||
}
|
||||
}
|
||||
}
|
||||
accessToken := ""
|
||||
if tokenData != nil && tokenData.IsAccessTokenValid() {
|
||||
accessToken = tokenData.AccessToken
|
||||
}
|
||||
if accessToken != "" {
|
||||
bootstrapCtx, bootstrapCancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
||||
installed := updater.EnsureManaged(bootstrapCtx, accessToken, os.Stderr)
|
||||
bootstrapCancel()
|
||||
@@ -1114,7 +1138,7 @@ func loadPlugins(engine *pipeline.Engine, runner executor.Runner) []*cobra.Comma
|
||||
|
||||
// 4. Start stdio MCP servers, discover tools, and build CLI commands
|
||||
for _, p := range allPlugins {
|
||||
for _, sc := range p.StdioClients() {
|
||||
for _, sc := range p.StdioClients(userCtx) {
|
||||
// Use background context so the subprocess lives for the CLI
|
||||
// process lifetime (not killed by a short timeout).
|
||||
if err := sc.Client.Start(context.Background()); err != nil {
|
||||
@@ -1405,7 +1429,8 @@ func registerStdioServer(p *plugin.Plugin, sc plugin.StdioServerClient, runner e
|
||||
}
|
||||
|
||||
AppendDynamicServer(descriptor)
|
||||
RegisterStdioClient(serverID, sc.Client)
|
||||
// Register with pluginName/serverKey format for cleanup by plugin name
|
||||
RegisterStdioClient(p.Manifest.Name+"/"+serverID, sc.Client)
|
||||
|
||||
// Convert tool descriptors to DetailTool entries for flag generation.
|
||||
detailsByID := make(map[string][]market.DetailTool)
|
||||
|
||||
@@ -408,3 +408,33 @@ func TestPrintExecutionError_RawStderrError_takes_precedence_over_JSON_mode(t *t
|
||||
t.Fatalf("stderr = %q, want raw PAT JSON", stderr.String())
|
||||
}
|
||||
}
|
||||
|
||||
// simulateExecuteWithPanic mirrors the recovery pattern in Execute():
|
||||
// named return + defer recover → exitCode = 5 on panic.
|
||||
func simulateExecuteWithPanic(doPanic bool) (exitCode int) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
exitCode = 5
|
||||
}
|
||||
}()
|
||||
if doPanic {
|
||||
panic("test panic")
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func TestExecute_panic_recovery_returns_exit_5(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(true)
|
||||
if code != 5 {
|
||||
t.Fatalf("panic recovery exitCode = %d, want 5", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_no_panic_returns_0(t *testing.T) {
|
||||
t.Parallel()
|
||||
code := simulateExecuteWithPanic(false)
|
||||
if code != 0 {
|
||||
t.Fatalf("no-panic exitCode = %d, want 0", code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ type findSkillsResponse struct {
|
||||
Result []CliSkillDTO `json:"result,omitempty"`
|
||||
}
|
||||
|
||||
// CliSkillDTO mirrors the old cli response payload for `skill find`.
|
||||
// CliSkillDTO mirrors the old cli response payload for `skill search`.
|
||||
type CliSkillDTO struct {
|
||||
SkillID string `json:"skillId"`
|
||||
Name string `json:"name"`
|
||||
@@ -117,10 +117,11 @@ func buildSkillCommand() *cobra.Command {
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
newSkillAddCommand(),
|
||||
newSkillInstallCommand(),
|
||||
newSkillGetCommand(),
|
||||
newSkillFindCommand(),
|
||||
newSkillSearchHintCommand(),
|
||||
newSkillSearchCommand(),
|
||||
newSkillFindHintCommand(),
|
||||
newSkillAddHintCommand(),
|
||||
)
|
||||
return cmd
|
||||
}
|
||||
@@ -139,36 +140,37 @@ func newSkillGetCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillFindCommand() *cobra.Command {
|
||||
func newSkillSearchCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "find",
|
||||
Use: "search",
|
||||
Short: "从钉钉技能市场搜索技能",
|
||||
Long: "从钉钉技能市场搜索技能,根据关键词返回匹配的技能列表。",
|
||||
Example: " dws skill find --context 关键词",
|
||||
Example: " dws skill search --query 关键词",
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillFind,
|
||||
}
|
||||
cmd.Flags().String("context", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("context")
|
||||
cmd.Flags().String("query", "", "搜索关键词(必填)")
|
||||
_ = cmd.MarkFlagRequired("query")
|
||||
cmd.Flags().String("scopes", "", "查询范围,空格分隔。备选值:DingtalkMarket(钉钉市场)、OrgInternal(企业内部)。为空默认查市场技能")
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillSearchHintCommand() *cobra.Command {
|
||||
func newSkillFindHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "search",
|
||||
Short: "兼容旧用法,提示使用 skill find",
|
||||
Use: "find",
|
||||
Short: "兼容旧用法,提示使用 skill search",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill find --context <关键词>")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill search --query <关键词>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newSkillAddCommand() *cobra.Command {
|
||||
func newSkillInstallCommand() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "add <skillId> <target>",
|
||||
Use: "install <skillId> <target>",
|
||||
Short: "下载并安装技能到指定目录",
|
||||
Long: fmt.Sprintf(`从钉钉技能市场下载技能并安装到指定 Agent 目录。
|
||||
|
||||
@@ -185,9 +187,9 @@ func newSkillAddCommand() *cobra.Command {
|
||||
. -> 当前目录
|
||||
|
||||
示例:
|
||||
dws skill add skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill add skill-123 claude # 安装到 ~/.claude/skills/
|
||||
dws skill add skill-123 . # 安装到当前目录`, supportedTargets()),
|
||||
dws skill install skill-123 qoder # 安装到 ~/.qoder/skills/
|
||||
dws skill install skill-123 claude # 安装到 ~/.claude/skills/
|
||||
dws skill install skill-123 . # 安装到当前目录`, supportedTargets()),
|
||||
Args: cobra.ExactArgs(2),
|
||||
DisableAutoGenTag: true,
|
||||
RunE: runSkillAdd,
|
||||
@@ -196,6 +198,19 @@ func newSkillAddCommand() *cobra.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newSkillAddHintCommand() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "add",
|
||||
Short: "兼容旧用法,提示使用 skill install",
|
||||
Hidden: true,
|
||||
DisableAutoGenTag: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "use: dws skill install <skillId> <target>")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
skillID, _ := cmd.Flags().GetString("skill-id")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
@@ -216,13 +231,17 @@ func runSkillGet(cmd *cobra.Command, args []string) error {
|
||||
}
|
||||
|
||||
func runSkillFind(cmd *cobra.Command, args []string) error {
|
||||
keyword, _ := cmd.Flags().GetString("context")
|
||||
keyword, _ := cmd.Flags().GetString("query")
|
||||
scopes, _ := cmd.Flags().GetString("scopes")
|
||||
accessToken, err := loadSkillAccessToken()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiURL := fmt.Sprintf("%s/cli/find-skills?keyword=%s", skillAPIHost(), url.QueryEscape(strings.TrimSpace(keyword)))
|
||||
if scopes != "" {
|
||||
apiURL += "&scopes=" + url.QueryEscape(scopes)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, apiURL, nil)
|
||||
if err != nil {
|
||||
return apperrors.NewInternal(fmt.Sprintf("failed to create request: %v", err))
|
||||
|
||||
@@ -319,7 +319,7 @@ func TestExtractSkillZipPreventZipSlip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddCommandValidation(t *testing.T) {
|
||||
func TestSkillInstallCommandValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
@@ -328,19 +328,19 @@ func TestSkillAddCommandValidation(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "missing arguments",
|
||||
args: []string{"skill", "add"},
|
||||
args: []string{"skill", "install"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "missing target",
|
||||
args: []string{"skill", "add", "skill-123"},
|
||||
args: []string{"skill", "install", "skill-123"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
{
|
||||
name: "too many arguments",
|
||||
args: []string{"skill", "add", "skill-123", "qoder", "extra"},
|
||||
args: []string{"skill", "install", "skill-123", "qoder", "extra"},
|
||||
wantErr: true,
|
||||
errMsg: "accepts 2 arg(s)",
|
||||
},
|
||||
@@ -366,7 +366,7 @@ func TestSkillAddCommandValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
func TestSkillInstallInvalidTarget(t *testing.T) {
|
||||
// Setup: Create config directory with valid token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
@@ -384,7 +384,7 @@ func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "skill-123", "invalid-target"})
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "invalid-target"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -399,7 +399,7 @@ func TestSkillAddInvalidTarget(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddRequiresAuth(t *testing.T) {
|
||||
func TestSkillInstallRequiresAuth(t *testing.T) {
|
||||
// Setup: Create config directory without token
|
||||
tempDir := t.TempDir()
|
||||
configDir := filepath.Join(tempDir, "config")
|
||||
@@ -411,7 +411,7 @@ func TestSkillAddRequiresAuth(t *testing.T) {
|
||||
}
|
||||
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "skill-123", "qoder"})
|
||||
cmd.SetArgs([]string{"skill", "install", "skill-123", "qoder"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -563,16 +563,16 @@ func TestSkillCommandHelp(t *testing.T) {
|
||||
if !strings.Contains(output, "技能") {
|
||||
t.Errorf("help should mention '技能', got: %s", output)
|
||||
}
|
||||
for _, subcmd := range []string{"add", "find", "get"} {
|
||||
for _, subcmd := range []string{"install", "search", "get"} {
|
||||
if !strings.Contains(output, subcmd) {
|
||||
t.Errorf("help should mention %q subcommand, got: %s", subcmd, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillAddCommandHelp(t *testing.T) {
|
||||
func TestSkillInstallCommandHelp(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "add", "--help"})
|
||||
cmd.SetArgs([]string{"skill", "install", "--help"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -609,9 +609,9 @@ func TestSkillGetCommandValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillFindCommandValidation(t *testing.T) {
|
||||
func TestSkillSearchCommandValidation(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "find"})
|
||||
cmd.SetArgs([]string{"skill", "search"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -626,9 +626,9 @@ func TestSkillFindCommandValidation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSkillSearchHintCommand(t *testing.T) {
|
||||
func TestSkillFindHintCommand(t *testing.T) {
|
||||
cmd := NewRootCommand()
|
||||
cmd.SetArgs([]string{"skill", "search"})
|
||||
cmd.SetArgs([]string{"skill", "find"})
|
||||
|
||||
var out bytes.Buffer
|
||||
cmd.SetOut(&out)
|
||||
@@ -637,7 +637,7 @@ func TestSkillSearchHintCommand(t *testing.T) {
|
||||
if err := cmd.Execute(); err != nil {
|
||||
t.Fatalf("Execute() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "dws skill find --context") {
|
||||
if !strings.Contains(out.String(), "dws skill search --query") {
|
||||
t.Fatalf("output = %q, want legacy hint", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
@@ -37,11 +38,24 @@ func RegisterStdioClient(productID string, client *transport.StdioClient) {
|
||||
}
|
||||
|
||||
// LookupStdioClient returns the StdioClient registered for the given product ID.
|
||||
// The productID can be either the full key (pluginName/serverKey) or just the serverKey.
|
||||
// This supports backward compatibility with existing CanonicalProduct values.
|
||||
func LookupStdioClient(productID string) (*transport.StdioClient, bool) {
|
||||
stdioMu.RLock()
|
||||
defer stdioMu.RUnlock()
|
||||
c, ok := stdioClients[productID]
|
||||
return c, ok
|
||||
// Try exact match first
|
||||
if c, ok := stdioClients[productID]; ok {
|
||||
return c, true
|
||||
}
|
||||
// If not found, try matching by serverKey suffix (for backward compatibility)
|
||||
for id, c := range stdioClients {
|
||||
if idx := strings.LastIndex(id, "/"); idx >= 0 {
|
||||
if id[idx+1:] == productID {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// StdioEndpoint returns a virtual endpoint URL for a stdio-based MCP server.
|
||||
@@ -54,3 +68,52 @@ func StdioEndpoint(pluginName, serverKey string) string {
|
||||
func IsStdioEndpoint(endpoint string) bool {
|
||||
return strings.HasPrefix(endpoint, stdioEndpointScheme)
|
||||
}
|
||||
|
||||
// StopAllStdioClients stops all registered stdio clients.
|
||||
// This should be called on program exit to terminate child processes.
|
||||
func StopAllStdioClients() {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
for id, client := range stdioClients {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
}
|
||||
stdioClients = make(map[string]*transport.StdioClient)
|
||||
}
|
||||
|
||||
// StopStdioClient stops a specific stdio client by product ID.
|
||||
// Returns true if the client was found and stopped, false otherwise.
|
||||
func StopStdioClient(productID string) bool {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
client, ok := stdioClients[productID]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", productID, "error", err)
|
||||
}
|
||||
delete(stdioClients, productID)
|
||||
return true
|
||||
}
|
||||
|
||||
// StopStdioClientsByPlugin stops all stdio clients belonging to a plugin.
|
||||
// The productID format is "pluginName/serverKey". This function stops all
|
||||
// clients whose productID has the given pluginName prefix.
|
||||
func StopStdioClientsByPlugin(pluginName string) int {
|
||||
stdioMu.Lock()
|
||||
defer stdioMu.Unlock()
|
||||
prefix := pluginName + "/"
|
||||
count := 0
|
||||
for id, client := range stdioClients {
|
||||
if len(id) > len(prefix) && id[:len(prefix)] == prefix {
|
||||
if err := client.Stop(); err != nil {
|
||||
slog.Warn("failed to stop stdio client", "id", id, "error", err)
|
||||
}
|
||||
delete(stdioClients, id)
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ const (
|
||||
DefaultMarketBaseURL = "https://mcp.dingtalk.com"
|
||||
|
||||
// defaultDiscoveryTimeout bounds the time spent on live registry discovery.
|
||||
defaultDiscoveryTimeout = 10 * time.Second
|
||||
defaultDiscoveryTimeout = 4 * time.Second
|
||||
)
|
||||
|
||||
type CatalogLoader interface {
|
||||
|
||||
@@ -53,12 +53,13 @@ const (
|
||||
var errCLIServerSkipped = errors.New("server marked cli.skip")
|
||||
|
||||
type Service struct {
|
||||
MarketClient *market.Client
|
||||
Transport *transport.Client
|
||||
Cache *cache.Store
|
||||
Tenant string
|
||||
AuthIdentity string
|
||||
Logger *slog.Logger
|
||||
MarketClient *market.Client
|
||||
Transport *transport.Client
|
||||
Cache *cache.Store
|
||||
Tenant string
|
||||
AuthIdentity string
|
||||
Logger *slog.Logger
|
||||
PerServerTimeout time.Duration // overrides perServerDiscoveryTimeout when > 0
|
||||
}
|
||||
|
||||
type RuntimeServer struct {
|
||||
@@ -170,7 +171,7 @@ func (s *Service) DiscoverServerRuntime(ctx context.Context, server market.Serve
|
||||
}, nil
|
||||
}
|
||||
|
||||
const perServerDiscoveryTimeout = 5 * time.Second
|
||||
const perServerDiscoveryTimeout = 2 * time.Second
|
||||
|
||||
func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.ServerDescriptor) ([]RuntimeServer, []RuntimeFailure) {
|
||||
type discoveryResult struct {
|
||||
@@ -189,12 +190,16 @@ func (s *Service) DiscoverAllRuntime(ctx context.Context, servers []market.Serve
|
||||
}
|
||||
|
||||
ch := make(chan discoveryResult, len(filtered))
|
||||
serverTimeout := s.PerServerTimeout
|
||||
if serverTimeout <= 0 {
|
||||
serverTimeout = perServerDiscoveryTimeout
|
||||
}
|
||||
var wg sync.WaitGroup
|
||||
for _, srv := range filtered {
|
||||
wg.Add(1)
|
||||
go func(server market.ServerDescriptor) {
|
||||
defer wg.Done()
|
||||
serverCtx, cancel := context.WithTimeout(ctx, perServerDiscoveryTimeout)
|
||||
serverCtx, cancel := context.WithTimeout(ctx, serverTimeout)
|
||||
defer cancel()
|
||||
start := time.Now()
|
||||
rs, err := s.DiscoverServerRuntime(serverCtx, server)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cache"
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market"
|
||||
@@ -435,6 +436,60 @@ func TestParseDetailSchema(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscoverAllRuntime_TimeoutFallsBackToCache(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// done signals slow handlers to exit so srv.Close() can complete.
|
||||
done := make(chan struct{})
|
||||
// Server that blocks until signalled (simulates an unreachable MCP server).
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
http.Error(w, "timeout", http.StatusServiceUnavailable)
|
||||
}))
|
||||
// LIFO: close(done) runs first so handlers exit, then srv.Close() completes.
|
||||
defer srv.Close()
|
||||
defer close(done)
|
||||
|
||||
svc := newTestService(t, srv.URL, srv)
|
||||
// Override timeout so the test completes quickly.
|
||||
svc.PerServerTimeout = 80 * time.Millisecond
|
||||
|
||||
server := market.ServerDescriptor{
|
||||
Key: "slow-server",
|
||||
Endpoint: srv.URL + "/mcp",
|
||||
}
|
||||
|
||||
// Pre-populate the cache so the fallback has data to return.
|
||||
partition := "test-tenant/test-identity"
|
||||
_ = svc.Cache.SaveTools(partition, server.Key, cache.ToolsSnapshot{
|
||||
ServerKey: server.Key,
|
||||
ProtocolVersion: "2025-03-26",
|
||||
Tools: []transport.ToolDescriptor{
|
||||
{Name: "cached-tool", Description: "from cache"},
|
||||
},
|
||||
})
|
||||
|
||||
results, failures := svc.DiscoverAllRuntime(context.Background(), []market.ServerDescriptor{server})
|
||||
if len(failures) != 0 {
|
||||
t.Fatalf("failures count = %d, want 0 (expected cache fallback)", len(failures))
|
||||
}
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("results count = %d, want 1", len(results))
|
||||
}
|
||||
if !results[0].Degraded {
|
||||
t.Fatal("cache fallback result should be degraded")
|
||||
}
|
||||
if len(results[0].Tools) == 0 {
|
||||
t.Fatal("expected cached tools to be returned")
|
||||
}
|
||||
if results[0].Tools[0].Name != "cached-tool" {
|
||||
t.Fatalf("tool name = %q, want cached-tool", results[0].Tools[0].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPartition(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &Service{Tenant: "corp1", AuthIdentity: "user1"}
|
||||
|
||||
@@ -24,6 +24,13 @@ import (
|
||||
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
||||
)
|
||||
|
||||
// UserContext holds the minimal user identity fields injected into
|
||||
// stdio plugin subprocesses via environment variables.
|
||||
type UserContext struct {
|
||||
UserID string
|
||||
CorpID string
|
||||
}
|
||||
|
||||
// StdioServerClient pairs a transport.StdioClient with its server key.
|
||||
type StdioServerClient struct {
|
||||
Key string
|
||||
@@ -31,8 +38,11 @@ type StdioServerClient struct {
|
||||
}
|
||||
|
||||
// StdioClients returns StdioClient instances for all stdio-type MCP
|
||||
// servers declared by this plugin.
|
||||
func (p *Plugin) StdioClients() []StdioServerClient {
|
||||
// servers declared by this plugin. uc is the current user's identity;
|
||||
// if non-nil, DWS_USER_ID and DWS_CORP_ID are injected as environment
|
||||
// variables so that the subprocess can identify the caller without
|
||||
// implementing its own auth.
|
||||
func (p *Plugin) StdioClients(uc *UserContext) []StdioServerClient {
|
||||
var clients []StdioServerClient
|
||||
for key, srv := range p.Manifest.MCPServers {
|
||||
if srv.Type != "stdio" {
|
||||
@@ -60,6 +70,16 @@ func (p *Plugin) StdioClients() []StdioServerClient {
|
||||
env["DWS_PLUGIN_ROOT"] = p.Root
|
||||
env["DWS_PLUGIN_DATA"] = filepath.Join(filepath.Dir(filepath.Dir(p.Root)), "data", p.Manifest.Name)
|
||||
|
||||
// Inject user identity so the subprocess knows who is calling.
|
||||
if uc != nil {
|
||||
if uc.UserID != "" {
|
||||
env["DWS_USER_ID"] = uc.UserID
|
||||
}
|
||||
if uc.CorpID != "" {
|
||||
env["DWS_CORP_ID"] = uc.CorpID
|
||||
}
|
||||
}
|
||||
|
||||
sc := transport.NewStdioClient(command, args, env)
|
||||
clients = append(clients, StdioServerClient{Key: key, Client: sc})
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ func (r *ToolCallResult) UnmarshalJSON(data []byte) error {
|
||||
func defaultTransport() *http.Transport {
|
||||
return &http.Transport{
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
Timeout: 3 * time.Second,
|
||||
KeepAlive: 30 * time.Second,
|
||||
}).DialContext,
|
||||
TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12},
|
||||
|
||||
Reference in New Issue
Block a user