== LeafSpec command framework (internal/helpers/leaf.go) ==
Declarative command construction: LeafSpec/LeafFlag/NewLeafCommand with
Call (pluggable dispatch), LeafInt, PostMount, Trim, Validate. Collapses
per-command hand-written required validation, alias/env fallback, value
transform, and toolArgs assembly into one declarative path.
== devapp migration (28/31 commands) ==
All MCP-direct devapp leaf commands migrated to LeafSpec. Factories
(devAppCall/devAppCallCursor/devAppMeta) fold 33 repeated closures.
fakeDevAppRunner asserts toolArgs for every migrated command. 4 complex
commands (delete/robot submit/result/config) kept hand-written.
== Schema generation/consumption separation ==
- gen.go: isolated //go:generate pragmas from business code.
- command_meta.go: ResolveMeta(cliPath) -> CommandMeta{Identity,Safety,Selection}.
- command_safety.go: SafetyForCLIPath + RenderSafetyAnnotation; safety metadata
flows from embedded catalog into --help output.
- calendar.go HelpFunc fix: delegates to root HelpFunc at help-time.
- schema_catalog_structure.go: closed catalog structure validation gate.
== Registry + catalog per-product sharding ==
schema_command_registry and schema_catalog split into per-product shards,
eliminating concurrent-PR merge conflicts on these files.
== MCP metadata refresh tool ==
cmd/fetch_mcp_metadata: iterates 26 MCP server endpoints, merges with previous
data for cross-server interface_ref. make fetch-mcp-metadata target.
== AGENTS.md ==
Documents the generation/consumption split.
Verified: make policy exit 0, drift zero, all tests pass.
269 lines
7.8 KiB
Go
269 lines
7.8 KiB
Go
// Command fetch_mcp_metadata pulls tools/list from ALL live MCP server endpoints
|
|
// and writes a refreshed schema_mcp_metadata.json. This is DWS's equivalent of
|
|
// lark-cli's scripts/fetch_meta.py.
|
|
//
|
|
// Usage:
|
|
//
|
|
// dws auth login # ensure valid auth
|
|
// make fetch-mcp-metadata # runs this tool
|
|
//
|
|
// The tool loads auth from the DWS keychain, iterates all 26 static server
|
|
// endpoints (internal/syncdata.StaticServers), calls tools/list on each,
|
|
// merges results, and writes schema_mcp_metadata.json.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/auth"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/syncdata"
|
|
"github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/transport"
|
|
)
|
|
|
|
func main() {
|
|
output := flag.String("output", "internal/cli/schema_mcp_metadata.json", "output file path")
|
|
flag.Parse()
|
|
|
|
token := strings.TrimSpace(os.Getenv("DWS_ACCESS_TOKEN"))
|
|
if token == "" {
|
|
td, err := auth.LoadTokenDataKeychain()
|
|
if err == nil && td != nil && td.AccessToken != "" {
|
|
token = td.AccessToken
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: loaded token from keychain (%d chars)\n", len(token))
|
|
}
|
|
}
|
|
if token == "" {
|
|
fmt.Fprintln(os.Stderr, "fetch_mcp_metadata: no auth token. Run 'dws auth login' first.")
|
|
os.Exit(1)
|
|
}
|
|
|
|
client := transport.NewClient(&http.Client{Timeout: 60 * time.Second}).WithAuth(token, nil)
|
|
|
|
// Iterate ALL static server endpoints (26 servers covering all products).
|
|
servers := syncdata.StaticServers()
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: querying %d server endpoints\n", len(servers))
|
|
|
|
// Load CLI registry to build tool_name → interface_ref mapping.
|
|
registryMap := loadRegistryInterfaceRefs()
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: registry mapping: %d entries\n", len(registryMap))
|
|
|
|
// Load the previous schema_mcp_metadata.json to preserve hand-curated
|
|
// cross-server interface_ref mappings that automated matching can't derive.
|
|
prevData, prevErr := os.ReadFile(*output)
|
|
prevTools := map[string]map[string]any{}
|
|
if prevErr == nil {
|
|
var prev struct {
|
|
Tools map[string]map[string]any `json:"tools"`
|
|
}
|
|
if json.Unmarshal(prevData, &prev) == nil {
|
|
prevTools = prev.Tools
|
|
}
|
|
}
|
|
|
|
// Start from previous data (preserves cross-server refs), then overwrite
|
|
// with fresh MCP data where available.
|
|
allTools := make(map[string]map[string]any)
|
|
for k, v := range prevTools {
|
|
allTools[k] = v
|
|
}
|
|
totalRaw := 0
|
|
|
|
for _, srv := range servers {
|
|
endpoint := strings.TrimSpace(srv.Endpoint)
|
|
if endpoint == "" {
|
|
continue
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
result, err := client.ListTools(ctx, endpoint)
|
|
cancel()
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, " [skip] %s: %v\n", srv.ID, err)
|
|
continue
|
|
}
|
|
fmt.Fprintf(os.Stderr, " [ok] %s: %d tools\n", srv.ID, len(result.Tools))
|
|
totalRaw += len(result.Tools)
|
|
for _, tool := range result.Tools {
|
|
name := strings.TrimSpace(tool.Name)
|
|
if name == "" {
|
|
continue
|
|
}
|
|
// Try matching by server-prefixed canonical (e.g., "doc.copy_document").
|
|
canonicalKey := srv.ID + "." + name
|
|
ref, hasRef := registryMap[canonicalKey]
|
|
if !hasRef {
|
|
continue // skip MCP tools not in the CLI registry
|
|
}
|
|
if _, exists := allTools[canonicalKey]; exists {
|
|
continue
|
|
}
|
|
entry := map[string]any{
|
|
"title": tool.Title,
|
|
"description": tool.Description,
|
|
"interface_ref": ref,
|
|
}
|
|
if tool.InputSchema != nil {
|
|
entry["parameters"] = extractParams(tool.InputSchema)
|
|
}
|
|
allTools[canonicalKey] = entry
|
|
}
|
|
}
|
|
|
|
matched := 0
|
|
for _, t := range allTools {
|
|
if _, ok := t["interface_ref"]; ok {
|
|
matched++
|
|
}
|
|
}
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: MCP matched=%d, with interface_ref=%d\n", len(allTools), matched)
|
|
|
|
// Fill gaps: for registry canonicals not covered by MCP tools/list OR
|
|
// previous data, add stub entries (interface_ref only).
|
|
stubs := 0
|
|
for canonicalKey, ref := range registryMap {
|
|
if _, exists := allTools[canonicalKey]; exists {
|
|
continue
|
|
}
|
|
allTools[canonicalKey] = map[string]any{
|
|
"interface_ref": ref,
|
|
}
|
|
stubs++
|
|
}
|
|
if stubs > 0 {
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: added %d registry stubs (no MCP data, interface_ref only)\n", stubs)
|
|
}
|
|
|
|
// Compute coverage fields required by check-schema-catalog.sh.
|
|
sourceServices := len(servers)
|
|
surfaceTools := len(allTools)
|
|
|
|
metadata := map[string]any{
|
|
"version": 1,
|
|
"source": "mcp-tools-list+cli-registry",
|
|
"coverage": map[string]any{
|
|
"surface_scope": "source_revision",
|
|
"source_services": sourceServices,
|
|
"snapshot_services": sourceServices,
|
|
"missing_services": []string{},
|
|
"source_tools": totalRaw,
|
|
"surface_tools": surfaceTools,
|
|
"matched_tools": surfaceTools,
|
|
"aliased_tools": 0,
|
|
"unmatched_tools": 0,
|
|
},
|
|
"tools": allTools,
|
|
}
|
|
|
|
// source_revision: git commit hash (proves provenance).
|
|
if rev, err := os.ReadFile(".git/HEAD"); err == nil {
|
|
metadata["source_revision"] = strings.TrimSpace(string(rev))
|
|
}
|
|
|
|
data, err := json.MarshalIndent(metadata, "", " ")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: marshal failed: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
if err := os.WriteFile(*output, data, 0644); err != nil {
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: write %s failed: %v\n", *output, err)
|
|
os.Exit(1)
|
|
}
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: wrote %d tools to %s\n", len(allTools), *output)
|
|
}
|
|
|
|
// loadRegistryInterfaceRefs reads schema_command_registry.json and builds a
|
|
// canonical_path → {product_id, rpc_name} mapping for interface_ref injection.
|
|
func loadRegistryInterfaceRefs() map[string]map[string]string {
|
|
data, err := os.ReadFile("internal/cli/schema_command_registry.json")
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "fetch_mcp_metadata: warning: cannot read registry: %v\n", err)
|
|
return map[string]map[string]string{}
|
|
}
|
|
var reg struct {
|
|
Products []struct {
|
|
ID string `json:"id"`
|
|
Tools []struct {
|
|
CanonicalPath string `json:"canonical_path"`
|
|
} `json:"tools"`
|
|
} `json:"products"`
|
|
}
|
|
if err := json.Unmarshal(data, ®); err != nil {
|
|
return map[string]map[string]string{}
|
|
}
|
|
out := make(map[string]map[string]string)
|
|
for _, prod := range reg.Products {
|
|
for _, tool := range prod.Tools {
|
|
cp := strings.TrimSpace(tool.CanonicalPath)
|
|
if cp == "" || !strings.Contains(cp, ".") {
|
|
continue
|
|
}
|
|
parts := strings.SplitN(cp, ".", 2)
|
|
out[cp] = map[string]string{
|
|
"product_id": parts[0],
|
|
"rpc_name": parts[1],
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// extractParams converts a JSON Schema inputSchema (from MCP tools/list) into
|
|
// the flat param-name → metadata map used by schema_mcp_metadata.json.
|
|
func extractParams(inputSchema map[string]any) map[string]map[string]any {
|
|
if inputSchema == nil {
|
|
return nil
|
|
}
|
|
properties, ok := inputSchema["properties"].(map[string]any)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
requiredSet := map[string]bool{}
|
|
if req, ok := inputSchema["required"].([]any); ok {
|
|
for _, r := range req {
|
|
if s, ok := r.(string); ok {
|
|
requiredSet[s] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
params := make(map[string]map[string]any, len(properties))
|
|
for name, raw := range properties {
|
|
prop, ok := raw.(map[string]any)
|
|
if !ok {
|
|
continue
|
|
}
|
|
meta := map[string]any{}
|
|
if t, ok := prop["type"].(string); ok {
|
|
meta["type"] = t
|
|
}
|
|
if d, ok := prop["description"].(string); ok {
|
|
meta["description"] = d
|
|
}
|
|
if d, ok := prop["default"].(string); ok {
|
|
meta["default"] = d
|
|
}
|
|
if e, ok := prop["enum"].([]any); ok {
|
|
enums := make([]string, 0, len(e))
|
|
for _, v := range e {
|
|
if s, ok := v.(string); ok {
|
|
enums = append(enums, s)
|
|
}
|
|
}
|
|
if len(enums) > 0 {
|
|
meta["enum"] = enums
|
|
}
|
|
}
|
|
meta["required"] = requiredSet[name]
|
|
params[name] = meta
|
|
}
|
|
return params
|
|
}
|