Files
dingtalk-workspace-cli/cmd/fetch_mcp_metadata/main.go
T
玉澜 51dc237d8a feat: declarative LeafSpec command framework + schema generation/consumption separation
== 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.
2026-07-19 09:38:43 +08:00

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, &reg); 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
}