Compare commits

..
12 Commits
Author SHA1 Message Date
ugurtafrali 1ea8bad074 Reduce MCP discovery timeouts
When MCP plugins are unreachable, the discovery times out too slowly and blocks commands. Reduced the overall discovery timeout from 10s to 4s and per-server timeout from 5s to 2s. Made the per-server timeout configurable.
2026-04-17 14:40:41 +08:00
修雨 7b7aeadbbe Merge pull request #120 from FuShu-Yang/bugfix/plugin-system-fushu
feat(plugin): stdio subprocess identity injection and lifecycle management
2026-04-17 14:15:44 +08:00
扶疏 94ad422a9f style: fix code formatting 2026-04-17 12:19:27 +08:00
扶疏 93318f4a83 fix(plugin): stop stdio child processes on exit and before removal 2026-04-17 10:03:01 +08:00
扶疏 a14fd0250c feat(plugin): inject user identity (UserID, CorpID) into stdio plugin subprocesses 2026-04-17 10:02:26 +08:00
fantiu 4bf300d862 Merge pull request #117 from wxianfeng/main
feat(skill): add skill install command with new download API and multi-agent target support
2026-04-16 14:58:35 +08:00
github-actions[bot] 1a1fc531f5 chore: update coverage badge [skip ci] 2026-04-16 06:56:02 +00:00
wxianfeng 9fc570607f Merge remote-tracking branch 'refs/remotes/origin/main' 2026-04-16 14:53:43 +08:00
wxianfeng 4851d19141 Merge remote-tracking branch 'upstream/main' 2026-04-16 14:53:07 +08:00
xianfeng wang 42fb25d150 Merge branch 'DingTalk-Real-AI:main' into main 2026-04-16 14:45:48 +08:00
wxianfeng 416ad6571d skill opt 2026-04-16 14:45:15 +08:00
wxianfeng c5decb2f90 error opt 2026-04-16 12:40:57 +08:00
12 changed files with 274 additions and 55 deletions
+1 -1
View File
@@ -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

+2
View File
@@ -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
View File
@@ -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)
+30
View File
@@ -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)
}
}
+38 -19
View File
@@ -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))
+16 -16
View File
@@ -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())
}
}
+65 -2
View File
@@ -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
}
+1 -1
View File
@@ -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 {
+13 -8
View File
@@ -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)
+55
View File
@@ -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"}
+22 -2
View File
@@ -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})
}
+1 -1
View File
@@ -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},