Files
dingtalk-workspace-cli/internal/app/helper_tool_fetcher.go
修雨 5ed69744cc Merge wxianfeng/feat/dws-devapp into release/dws-devapp
解决 devapp→dev 重构与 connect/数字分身工作的合并冲突,方向:全面对齐
wxianfeng 的 dev 命名空间,保留我方两处实质内容(动态端点 + connect 功能)。

冲突解决要点:
- release.yml:取 wxianfeng 更干净的 npm 守卫(repository_owner + 注释);
  保留我新增的「-dws-devapp 翻成非 prerelease+latest」步骤
- devapp.go / devapp_test.go / 路由文档:取 wxianfeng(新 MCP 工具名
  *_dev_app_*/extension、新增 event 工具、删 robot create、config 合 upsert)
- 端点:保留我方动态 devappMCPEndpoint()(网关 base 推导、生产默认),
  否则会把「建号对所有人打预发」的 bug 倒灌回来;同步修 helper_tool_fetcher.go
  与 direct_runtime_test.go 改用该函数 + wxianfeng 工具名 list_dev_app
- devapp_connect.go:connect 命令对齐 dev connect 命名,保留我方 --agent-cmd
  自研渠道示例(#37),建号引用修正为真实存在的 robot submit
- skills:跟随 wxianfeng 删除旧 dingtalk-devapp(已被重写的 dingtalk-dev 覆盖)
- robot-quickstart.md:命令全改 dws dev(建号两步 submit+result、dev connect),
  下载源改 wxianfeng,去掉写死版本号用 releases/latest

验证:go build ./... 通过;go vet 干净;internal/app、internal/helpers 单测全过
2026-06-23 10:21:29 +08:00

137 lines
4.5 KiB
Go

// 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 app
import (
"context"
"fmt"
"sync"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cli"
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
)
// newHelperToolFetcher returns a cli.HelperToolFetcher that loads a helper MCP
// server's tools/list LIVE (by source) and projects each tool into a
// cli.HelperToolSchema (name, description, inputSchema properties/required). It
// is injected into the schema command so the cli package can render
// `dws schema dev.*` from real server schema without importing app/transport.
//
// Sources: "op-app" backs the dev app commands (pinned endpoint); "devdoc"
// backs `dws dev doc search` (endpoint resolved dynamically, see
// helperSourceEndpoint). Results are memoized per source per process so
// repeated `dws schema dev.*` hit the network at most once per source. A failed
// fetch is not cached, allowing a later retry within the same process.
func newHelperToolFetcher() cli.HelperToolFetcher {
var (
mu sync.Mutex
cached = map[string]map[string]cli.HelperToolSchema{}
)
return func(ctx context.Context, source string) (map[string]cli.HelperToolSchema, error) {
mu.Lock()
if got, ok := cached[source]; ok {
mu.Unlock()
return got, nil
}
mu.Unlock()
endpoint, err := helperSourceEndpoint(source)
if err != nil {
return nil, err
}
schemas, err := fetchHelperToolSchemas(ctx, endpoint)
if err != nil {
return nil, err
}
mu.Lock()
cached[source] = schemas
mu.Unlock()
return schemas, nil
}
}
// helperSourceEndpoint maps a schema source to its MCP endpoint. op-app (dev
// app) is pinned in source (devappMCPEndpoint, derived from the active gateway
// base — production by default, pre when ~/.dws/mcp_url points at pre); other
// sources (e.g. devdoc) are resolved the same way the runner resolves a product
// endpoint — env override → discovery → edition StaticServers/SupplementServers.
func helperSourceEndpoint(source string) (string, error) {
switch source {
case "", "op-app", "devapp":
return devappMCPEndpoint(), nil
default:
if endpoint, ok := directRuntimeEndpoint(source, ""); ok {
return endpoint, nil
}
return "", fmt.Errorf("no MCP endpoint resolved for source %q (not injected by edition/discovery)", source)
}
}
// fetchHelperToolSchemas performs the live tools/list call against endpoint and
// converts the descriptors. Auth and identity headers are resolved the same way
// the runner does for direct-runtime invocations.
func fetchHelperToolSchemas(ctx context.Context, endpoint string) (map[string]cli.HelperToolSchema, error) {
token := resolveRuntimeAuthToken(ctx, "")
headers := resolveIdentityHeaders()
client := transport.NewClient(nil).WithAuth(token, headers)
result, err := client.ListTools(ctx, endpoint)
if err != nil {
return nil, err
}
out := make(map[string]cli.HelperToolSchema, len(result.Tools))
for _, td := range result.Tools {
out[td.Name] = cli.HelperToolSchema{
Name: td.Name,
Description: td.Description,
Properties: inputSchemaProperties(td.InputSchema),
Required: inputSchemaRequired(td.InputSchema),
}
}
return out, nil
}
// inputSchemaProperties pulls the "properties" object out of a deserialized
// MCP inputSchema map. Returns an empty (non-nil) map when absent.
func inputSchemaProperties(schema map[string]any) map[string]any {
if schema == nil {
return map[string]any{}
}
props, _ := schema["properties"].(map[string]any)
if props == nil {
return map[string]any{}
}
return props
}
// inputSchemaRequired pulls the "required" string list out of a deserialized
// MCP inputSchema map.
func inputSchemaRequired(schema map[string]any) []string {
if schema == nil {
return nil
}
raw, ok := schema["required"].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, v := range raw {
if s, ok := v.(string); ok && s != "" {
out = append(out, s)
}
}
return out
}