feat: add tool-level endpoint routing for multi-endpoint products
- 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
This commit is contained in:
@@ -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_<PRODUCT>_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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user