From c1893f40fc3a9e596b17a91c2244fc3f778ec7f6 Mon Sep 17 00:00:00 2001 From: audanye-sudo Date: Sun, 29 Mar 2026 16:29:35 +0800 Subject: [PATCH] feat: add tool-level endpoint routing for multi-endpoint products MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Build tool → endpoint mapping from CLI tools and overrides - directRuntimeEndpoint resolves by tool name before product fallback - Support dynamic command routing with compat layer updates --- internal/app/direct_runtime.go | 51 ++++++-- internal/app/recovery_command.go | 6 +- internal/app/runner.go | 2 +- internal/cli/loader.go | 16 +++ internal/compat/dynamic_commands.go | 43 ++++++- internal/compat/dynamic_commands_test.go | 145 +++++++++++++++++++++++ internal/market/registry.go | 1 + 7 files changed, 250 insertions(+), 14 deletions(-) create mode 100644 internal/compat/dynamic_commands_test.go diff --git a/internal/app/direct_runtime.go b/internal/app/direct_runtime.go index c49d52f4..4c34c3e4 100644 --- a/internal/app/direct_runtime.go +++ b/internal/app/direct_runtime.go @@ -24,10 +24,11 @@ import ( ) var ( - dynamicMu sync.RWMutex - dynamicEndpoints map[string]string - dynamicProducts map[string]bool - dynamicAliases map[string]string + dynamicMu sync.RWMutex + dynamicEndpoints map[string]string + dynamicProducts map[string]bool + dynamicAliases map[string]string + dynamicToolEndpoints map[string]string // tool name → endpoint ) var legacyDirectRuntimeAliases = map[string]string{ @@ -46,6 +47,7 @@ func SetDynamicServers(servers []market.ServerDescriptor) { endpoints := make(map[string]string) products := make(map[string]bool) aliases := make(map[string]string) + toolEndpoints := make(map[string]string) for _, server := range servers { if server.CLI.Skip { continue @@ -70,10 +72,26 @@ func SetDynamicServers(servers []market.ServerDescriptor) { aliases[alias] = id } } + // Build tool → endpoint mapping from CLI tools and overrides. + if endpoint != "" { + for _, tool := range server.CLI.Tools { + toolName := strings.TrimSpace(tool.Name) + if toolName != "" { + toolEndpoints[toolName] = endpoint + } + } + for toolName := range server.CLI.ToolOverrides { + toolName = strings.TrimSpace(toolName) + if toolName != "" { + toolEndpoints[toolName] = endpoint + } + } + } } dynamicEndpoints = endpoints dynamicProducts = products dynamicAliases = aliases + dynamicToolEndpoints = toolEndpoints } func shouldUseDirectRuntime(invocation executor.Invocation) bool { @@ -88,11 +106,9 @@ func shouldUseDirectRuntime(invocation executor.Invocation) bool { } } -func directRuntimeEndpoint(productID string) (string, bool) { +func directRuntimeEndpoint(productID, toolName string) (string, bool) { + // Priority 0: env-var override always wins (DINGTALK__MCP_URL). normalized := normalizeDirectRuntimeProductID(productID) - dynamicMu.RLock() - de := dynamicEndpoints - dynamicMu.RUnlock() for _, candidate := range []string{strings.TrimSpace(productID), normalized} { if candidate == "" { continue @@ -100,6 +116,25 @@ func directRuntimeEndpoint(productID string) (string, bool) { if override, ok := productEndpointOverride(candidate); ok { return override, true } + } + + dynamicMu.RLock() + de := dynamicEndpoints + te := dynamicToolEndpoints + dynamicMu.RUnlock() + + // Priority 1: tool-level endpoint (resolves multi-endpoint products). + if tool := strings.TrimSpace(toolName); tool != "" && te != nil { + if endpoint, ok := te[tool]; ok { + return endpoint, true + } + } + + // Priority 2: product-level endpoint. + for _, candidate := range []string{strings.TrimSpace(productID), normalized} { + if candidate == "" { + continue + } if de != nil { if endpoint, ok := de[candidate]; ok { return endpoint, true diff --git a/internal/app/recovery_command.go b/internal/app/recovery_command.go index 30ed2e42..514c6c47 100644 --- a/internal/app/recovery_command.go +++ b/internal/app/recovery_command.go @@ -324,7 +324,7 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName if r == nil || r.transport == nil { return nil, fmt.Errorf("recovery runtime not initialized") } - endpoint, err := r.resolveEndpoint(ctx, serverID) + endpoint, err := r.resolveEndpoint(ctx, serverID, toolName) if err != nil { return nil, err } @@ -344,8 +344,8 @@ func (r *recoveryRuntime) CallToolDirect(ctx context.Context, serverID, toolName return &result, nil } -func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID string) (string, error) { - if endpoint, ok := directRuntimeEndpoint(productID); ok { +func (r *recoveryRuntime) resolveEndpoint(ctx context.Context, productID, toolName string) (string, error) { + if endpoint, ok := directRuntimeEndpoint(productID, toolName); ok { return endpoint, nil } if r == nil || r.loader == nil { diff --git a/internal/app/runner.go b/internal/app/runner.go index 4f32ba95..6c294ecc 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -88,7 +88,7 @@ func (r *runtimeRunner) Run(ctx context.Context, invocation executor.Invocation) } if shouldUseDirectRuntime(invocation) { - if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct); ok { + if endpoint, ok := directRuntimeEndpoint(invocation.CanonicalProduct, invocation.Tool); ok { return r.executeInvocation(ctx, endpoint, invocation) } } diff --git a/internal/cli/loader.go b/internal/cli/loader.go index ffb89406..8f6f094e 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -50,6 +50,22 @@ func (l StaticLoader) Load(_ context.Context) (ir.Catalog, error) { return l.Catalog, nil } +// CatalogLoaderFrom creates a CatalogLoader that returns a +// pre-loaded catalog and error. This allows multiple consumers +// (schema command, MCP command tree) to share one discovery result. +func CatalogLoaderFrom(catalog ir.Catalog, err error) CatalogLoader { + return &preloadedLoader{catalog: catalog, err: err} +} + +type preloadedLoader struct { + catalog ir.Catalog + err error +} + +func (l *preloadedLoader) Load(_ context.Context) (ir.Catalog, error) { + return l.catalog, l.err +} + type FixtureLoader struct { Path string } diff --git a/internal/compat/dynamic_commands.go b/internal/compat/dynamic_commands.go index ae9c8b82..af48d0e5 100644 --- a/internal/compat/dynamic_commands.go +++ b/internal/compat/dynamic_commands.go @@ -35,7 +35,12 @@ import ( // // Conversion rules reference: docs/mcp-to-cli-conversion.md func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Runner, detailsByID map[string][]market.DetailTool) []*cobra.Command { - var commands []*cobra.Command + type builtCmd struct { + cmd *cobra.Command + parent string // cli.Parent: attach as sub-command of this top-level command + } + + var built []builtCmd for _, server := range servers { cli := server.CLI // §1.5: cli.skip → skip entire service @@ -136,7 +141,41 @@ func BuildDynamicCommands(servers []market.ServerDescriptor, runner executor.Run } } - commands = append(commands, rootCmd) + built = append(built, builtCmd{cmd: rootCmd, parent: strings.TrimSpace(cli.Parent)}) + } + + // Collect top-level commands first, then attach child commands via cli.Parent. + topLevel := make(map[string]*cobra.Command) + var topOrder []string + var children []builtCmd + + for _, b := range built { + if b.parent == "" { + name := b.cmd.Name() + if _, exists := topLevel[name]; !exists { + topOrder = append(topOrder, name) + } + topLevel[name] = b.cmd + } else { + children = append(children, b) + } + } + for _, child := range children { + if parent, ok := topLevel[child.parent]; ok { + parent.AddCommand(child.cmd) + } else { + // Parent not found among dynamic commands; emit as top-level. + name := child.cmd.Name() + if _, exists := topLevel[name]; !exists { + topOrder = append(topOrder, name) + } + topLevel[name] = child.cmd + } + } + + commands := make([]*cobra.Command, 0, len(topLevel)) + for _, name := range topOrder { + commands = append(commands, topLevel[name]) } return commands } diff --git a/internal/compat/dynamic_commands_test.go b/internal/compat/dynamic_commands_test.go new file mode 100644 index 00000000..68f06cc2 --- /dev/null +++ b/internal/compat/dynamic_commands_test.go @@ -0,0 +1,145 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package compat + +import ( + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/market" +) + +func TestBuildDynamicCommands_ParentNesting(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-chat", + CLI: market.CLIOverlay{ + ID: "group-chat", + Command: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "list_conversations": {CLIName: "list"}, + }, + }, + }, + { + Endpoint: "https://endpoint-bot", + CLI: market.CLIOverlay{ + ID: "bot", + Command: "bot", + Parent: "chat", + ToolOverrides: map[string]market.CLIToolOverride{ + "send_robot_message": {CLIName: "send"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + // Should produce only one top-level command: "chat" + if len(cmds) != 1 { + names := make([]string, len(cmds)) + for i, c := range cmds { + names[i] = c.Name() + } + t.Fatalf("expected 1 top-level command, got %d: %v", len(cmds), names) + } + if cmds[0].Name() != "chat" { + t.Fatalf("expected top-level command 'chat', got %q", cmds[0].Name()) + } + + // "bot" should be a sub-command of "chat" + found := false + for _, sub := range cmds[0].Commands() { + if sub.Name() == "bot" { + found = true + // "bot" should have its own sub-command "send" + hasSend := false + for _, leaf := range sub.Commands() { + if leaf.Name() == "send" { + hasSend = true + } + } + if !hasSend { + t.Fatal("expected 'bot' to have sub-command 'send'") + } + } + } + if !found { + t.Fatal("expected 'bot' as sub-command of 'chat'") + } +} + +func TestBuildDynamicCommands_ParentNotFound(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-orphan", + CLI: market.CLIOverlay{ + ID: "orphan", + Command: "orphan", + Parent: "nonexistent", + ToolOverrides: map[string]market.CLIToolOverride{ + "do_something": {CLIName: "do"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + // Parent not found, should fall back to top-level + if len(cmds) != 1 { + t.Fatalf("expected 1 top-level command, got %d", len(cmds)) + } + if cmds[0].Name() != "orphan" { + t.Fatalf("expected top-level command 'orphan', got %q", cmds[0].Name()) + } +} + +func TestBuildDynamicCommands_NoParent(t *testing.T) { + t.Parallel() + + servers := []market.ServerDescriptor{ + { + Endpoint: "https://endpoint-a", + CLI: market.CLIOverlay{ + ID: "svc-a", + Command: "alpha", + ToolOverrides: map[string]market.CLIToolOverride{ + "tool_a": {CLIName: "run"}, + }, + }, + }, + { + Endpoint: "https://endpoint-b", + CLI: market.CLIOverlay{ + ID: "svc-b", + Command: "beta", + ToolOverrides: map[string]market.CLIToolOverride{ + "tool_b": {CLIName: "exec"}, + }, + }, + }, + } + + cmds := BuildDynamicCommands(servers, executor.EchoRunner{}, nil) + + if len(cmds) != 2 { + t.Fatalf("expected 2 top-level commands, got %d", len(cmds)) + } +} diff --git a/internal/market/registry.go b/internal/market/registry.go index ff7b6d8e..070b622e 100644 --- a/internal/market/registry.go +++ b/internal/market/registry.go @@ -87,6 +87,7 @@ type LifecycleInfo struct { type CLIOverlay struct { ID string `json:"id"` Command string `json:"command"` + Parent string `json:"parent,omitempty"` Description string `json:"description"` Prefixes []string `json:"prefixes"` Aliases []string `json:"aliases"`