// 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 cli import ( "bufio" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "sort" "strconv" "strings" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/cobracmd" apperrors "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/errors" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/executor" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/ir" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/output" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/pkg/convert" "github.com/spf13/cobra" ) type FlagKind string const ( flagString FlagKind = "string" flagInteger FlagKind = "integer" flagNumber FlagKind = "number" flagBoolean FlagKind = "boolean" flagStringArray FlagKind = "string_array" flagIntegerList FlagKind = "integer_array" flagNumberList FlagKind = "number_array" flagBooleanList FlagKind = "boolean_array" flagJSON FlagKind = "json" ) type FlagSpec struct { PropertyName string FlagName string Alias string Shorthand string Kind FlagKind Description string } func NewMCPCommand(ctx context.Context, loader CatalogLoader, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { catalog, loadErr := loader.Load(ctx) longDescription := "Reserved canonical runtime surface. Tools are generated from the shared Tool IR under dws mcp." if loadErr != nil { longDescription += fmt.Sprintf("\n\nDiscovery note: %v", loadErr) } if len(catalog.Products) == 0 { longDescription += "\n\nNo canonical products are currently loaded. Set DWS_CATALOG_FIXTURE to populate the surface." } cmd := &cobra.Command{ Use: "mcp", Short: "Canonical MCP-derived CLI surface", Long: longDescription, Hidden: false, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } if loadErr != nil { cmd.Args = cobra.ArbitraryArgs cmd.RunE = func(cmd *cobra.Command, args []string) error { if len(args) == 0 { return cmd.Help() } return loadErr } return cmd } for _, product := range catalog.Products { if product.CLI != nil && product.CLI.Skip { continue } productCommand := newProductCommand(product, runner, engine) cmd.AddCommand(productCommand) addGroupedProductAlias(cmd, product, runner, engine) } return cmd } func NewSchemaCommand(loader CatalogLoader, helperTools HelperToolFetcher) *cobra.Command { cmd := &cobra.Command{ Use: "schema [path]", Short: "查看 MCP 工具 Schema (产品列表 / 工具参数)", Long: `查看已发现的 MCP 产品和工具的 Schema 元数据。 不带参数时列出所有产品及其工具数量;带路径时输出该工具的完整 输入 Schema(JSON Schema 格式)、输出 Schema、授权元数据、MCP 注解和 CLI 层的 flag overlay(alias/transform/env_default)。 路径支持三种写法: product.rpc_name 规范路径 (e.g. ding.send_ding_message) product.group.cli_name CLI 点路径 (e.g. ding.message.send) "product group cli_name" CLI 空格/斜杠路径 (e.g. "ding message send") 示例: dws schema # 列出所有产品 dws schema ding.send_ding_message # 规范路径 dws schema "ding message send" # CLI 路径(空格) dws schema --cli-path "ding message send" # 同上,显式 flag(脚本友好) dws schema calendar.create_event --jq '.tool.auth' dws schema -f pretty ding.send_ding_message # ANSI 彩色分区展示 dws schema --jq '.tool.flag_overlay' # 只看 CLI overlay helper-only 命令组(如 dev,不走服务发现)也支持查询,schema 从 op-app MCP 服务端实时拉取,输出对齐 gws 的扁平格式(parameters 内联 required, 键为 CLI flag): dws schema "dev app robot config" # 实时 MCP 参数 schema(gws-flat) dws schema "dev app" # 列出该分组下的子命令`, Args: cobra.MaximumNArgs(1), DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { cliPath, _ := cmd.Flags().GetString("cli-path") cliPath = strings.TrimSpace(cliPath) if cliPath != "" { if len(args) > 0 { return apperrors.NewValidation("--cli-path and positional argument are mutually exclusive") } args = []string{cliPath} } // Helper-only subtrees (e.g. `dws dev ...`) aren't in the discovery // catalog; their schema CONTENT is fetched LIVE from the helper's // pinned MCP server (op-app) and rendered in the gws-flat shape, so // `dws schema "dev app robot config"` answers without touching // discovery. Only the `dev` root claims this path; everything else // falls through to the catalog below. if len(args) > 0 { payload, ok, err := renderHelperSchema(cmd.Context(), cmd.Root(), args[0], helperTools) if err != nil { return err } if ok { return output.WriteFiltered( cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd), ) } } catalog, err := loader.Load(cmd.Context()) if err != nil { var degraded *CatalogDegraded if errors.As(err, °raded) { fmt.Fprintf(cmd.ErrOrStderr(), "hint: %s\n", degraded.Hint) payload := map[string]any{ "kind": "schema", "count": 0, "products": []any{}, "degraded": true, "reason": string(degraded.Reason), "hint": degraded.Hint, } return output.WriteFiltered( cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd), ) } return err } payload, err := schemaPayload(catalog, args) if err != nil { return err } // Append helper-only subtrees (e.g. `dev`) to the no-arg product // listing so browsing all products also surfaces helper commands. if len(args) == 0 { if helpers := helperProductSummaries(cmd.Root()); len(helpers) > 0 { if products, ok := payload["products"].([]map[string]any); ok { payload["products"] = append(products, helpers...) payload["count"] = len(payload["products"].([]map[string]any)) } } } return output.WriteFiltered( cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), payload, output.ResolveFields(cmd), output.ResolveJQ(cmd), ) }, } cmd.Flags().String("cli-path", "", "按 CLI 命令路径查询 (等同于位置参数,便于脚本使用无需转义)") return cmd } func BuildFlagSpecs(schema map[string]any, hints map[string]ir.CLIFlagHint) []FlagSpec { properties, ok := nestedMap(schema, "properties") if !ok { return nil } keys := make([]string, 0, len(properties)) for key := range properties { keys = append(keys, key) } sort.Strings(keys) specs := make([]FlagSpec, 0, len(keys)) for _, key := range keys { propertySchema, ok := properties[key].(map[string]any) if !ok { continue } kind, ok := flagKindForSchema(propertySchema) if !ok { continue } specs = append(specs, FlagSpec{ PropertyName: key, FlagName: strings.ReplaceAll(key, "_", "-"), Alias: strings.TrimSpace(hints[key].Alias), Shorthand: strings.TrimSpace(hints[key].Shorthand), Kind: kind, Description: schemaDescription(propertySchema), }) } return specs } func newProductCommand(product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { shortDescription := product.DisplayName if strings.TrimSpace(product.Description) != "" { shortDescription = product.Description } if shortDescription == "" { shortDescription = product.ID } aliases := make([]string, 0, 2) seenAlias := map[string]bool{product.ID: true} addAlias := func(s string) { s = strings.TrimSpace(s) if s == "" || seenAlias[s] { return } seenAlias[s] = true aliases = append(aliases, s) } if preferred := preferredProductRouteToken(product); preferred != "" { addAlias(preferred) } // Consume only cli.Aliases (canonical alternate-name field). // cli.Prefixes is the tool-name-prefix pool consumed by deriveCommandName; // treating prefixes[1:] as aliases over-registers names the wukong edition // does not expose, breaking cross-edition parity. if product.CLI != nil { for _, a := range product.CLI.Aliases { addAlias(a) } } cmd := &cobra.Command{ Use: product.ID, Aliases: aliases, Short: shortDescription, Hidden: product.CLI != nil && product.CLI.Hidden, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } if product.CLI != nil && strings.TrimSpace(product.CLI.Group) != "" { cmd.Long = fmt.Sprintf("%s\n\nGroup: %s", shortDescription, product.CLI.Group) } if warning := lifecycleWarning(product); warning != "" { if strings.TrimSpace(cmd.Long) == "" { cmd.Long = shortDescription } cmd.Long = strings.TrimSpace(cmd.Long + "\n\nLifecycle: " + warning) } for _, tool := range product.Tools { cmd.AddCommand(newToolCommand(product, tool, runner, engine)) } // Register phase: notify the pipeline that a product and its // tools have been added to the command tree. This runs once at // startup (not per-request) and enables handlers to inspect or // enrich the registered command surface. if engine != nil && engine.HasHandlers(pipeline.Register) { pctx := &pipeline.Context{ Command: product.ID, } // Best-effort — registration errors are logged but do not // prevent the CLI from starting. if pipeErr := engine.RunPhase(pipeline.Register, pctx); pipeErr != nil { slog.Debug("pipeline register phase", "product", product.ID, "error", pipeErr) } else { slog.Debug("pipeline register", "product", product.ID, "tool_count", len(product.Tools), ) } } return cmd } func addGroupedProductAlias(root *cobra.Command, product ir.CanonicalProduct, runner executor.Runner, engine *pipeline.Engine) { if root == nil || product.CLI == nil { return } groupPath := splitRouteTokens(product.CLI.Group) if len(groupPath) == 0 { return } commandPath := splitRouteTokens(product.CLI.Command) if len(commandPath) == 0 { commandPath = []string{product.ID} } fullPath := append(append([]string{}, groupPath...), commandPath...) if len(fullPath) == 0 { return } parent := root for _, token := range fullPath[:len(fullPath)-1] { existing := cobracmd.ChildByName(parent, token) if existing != nil { parent = existing continue } groupCommand := &cobra.Command{ Use: token, Short: fmt.Sprintf("Canonical group %s", token), Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { return cmd.Help() }, } parent.AddCommand(groupCommand) parent = groupCommand } leaf := fullPath[len(fullPath)-1] if cobracmd.ChildByName(parent, leaf) != nil { return } aliasProduct := product if aliasProduct.CLI != nil { cliCopy := *aliasProduct.CLI cliCopy.Command = "" cliCopy.Group = "" aliasProduct.CLI = &cliCopy } productCommand := newProductCommand(aliasProduct, runner, engine) productCommand.Use = leaf productCommand.Aliases = nil if leaf != aliasProduct.ID { productCommand.Aliases = append(productCommand.Aliases, aliasProduct.ID) } parent.AddCommand(productCommand) } func newToolCommand(product ir.CanonicalProduct, tool ir.ToolDescriptor, runner executor.Runner, engine *pipeline.Engine) *cobra.Command { shortDescription := tool.Title if strings.TrimSpace(tool.Description) != "" { shortDescription = tool.Description } specs := BuildFlagSpecs(tool.InputSchema, tool.FlagHints) use := strings.TrimSpace(tool.CLIName) if use == "" { use = tool.RPCName } aliases := make([]string, 0, 1) if use != tool.RPCName { aliases = append(aliases, tool.RPCName) } cmd := &cobra.Command{ Use: use, Aliases: aliases, Short: shortDescription, Hidden: tool.Hidden, Args: cobra.NoArgs, DisableAutoGenTag: true, RunE: func(cmd *cobra.Command, args []string) error { if warning := lifecycleWarning(product); warning != "" { _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: %s\n", warning) } dryRun := false if cmd.Flags().Lookup("dry-run") != nil { value, err := cmd.Flags().GetBool("dry-run") if err != nil { return apperrors.NewInternal("failed to read --dry-run") } dryRun = value } // One guard per invocation ensures stdin is read at most once. guard := NewStdinGuard() jsonPayload, err := cmd.Flags().GetString("json") if err != nil { return apperrors.NewInternal("failed to read --json") } // Resolve @file / @- for --json flag. jsonPayload, err = ResolveInputSource(jsonPayload, "json", guard) if err != nil { return err } paramsPayload, err := cmd.Flags().GetString("params") if err != nil { return apperrors.NewInternal("failed to read --params") } // Resolve @file / @- for all string-typed override flags BEFORE // the implicit stdin fallback, so explicit @- in any flag takes // priority over the implicit pipe read. overrides, err := collectOverrides(cmd, specs, guard) if err != nil { return err } // Implicit stdin fallback (lowest priority): if no --json was // given and no flag claimed stdin via @-, read from pipe. if jsonPayload == "" && !guard.Claimed() && StdinIsPipe() { if claimErr := guard.Claim("implicit stdin (pipe)"); claimErr != nil { return claimErr } stdinData, stdinErr := ReadStdin() if stdinErr != nil { return stdinErr } jsonPayload = stdinData } params, err := executor.MergePayloads(jsonPayload, paramsPayload, overrides) if err != nil { return err } // PostParse: normalise parameter values (date formats, // booleans, enums) using the tool's input schema. if engine != nil && engine.HasHandlers(pipeline.PostParse) { pctx := &pipeline.Context{ Command: tool.CanonicalPath, Params: params, Schema: tool.InputSchema, } if pipeErr := engine.RunPhase(pipeline.PostParse, pctx); pipeErr != nil { return pipeErr } params = pctx.Params for _, c := range pctx.Corrections { slog.Debug("pipeline correction", "phase", "post-parse", "handler", c.Handler, "kind", c.Kind, "field", c.Field, "original", c.Original, "corrected", c.Corrected, ) } } if err := ValidateInputSchema(params, tool.InputSchema); err != nil { return err } if !dryRun { if err := confirmSensitiveTool(cmd, tool, guard); err != nil { return err } } // PreRequest: last chance to inspect/mutate payload before // the JSON-RPC call is dispatched. if engine != nil && engine.HasHandlers(pipeline.PreRequest) { pctx := &pipeline.Context{ Command: tool.CanonicalPath, Params: params, Schema: tool.InputSchema, Payload: params, } if pipeErr := engine.RunPhase(pipeline.PreRequest, pctx); pipeErr != nil { return pipeErr } params = pctx.Params slog.Debug("pipeline pre-request", "command", tool.CanonicalPath, "param_count", len(params), ) } invocation := executor.NewInvocation(product, tool, params) invocation.DryRun = dryRun result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err } // PostResponse: transform or enrich the response before // writing it to stdout. if engine != nil && engine.HasHandlers(pipeline.PostResponse) { pctx := &pipeline.Context{ Command: tool.CanonicalPath, Params: params, Schema: tool.InputSchema, Response: result.Response, } if pipeErr := engine.RunPhase(pipeline.PostResponse, pctx); pipeErr != nil { return pipeErr } result.Response = pctx.Response slog.Debug("pipeline post-response", "command", tool.CanonicalPath, "has_response", result.Response != nil, ) } if warning := lifecycleWarning(product); warning != "" { if result.Response == nil { result.Response = map[string]any{} } result.Response["warning"] = warning } return output.WriteFiltered( cmd.OutOrStdout(), output.ResolveFormat(cmd, output.FormatJSON), result, output.ResolveFields(cmd), output.ResolveJQ(cmd), ) }, } cmd.Flags().String("json", "", "Base JSON object payload for this tool invocation") cmd.Flags().String("params", "", "Additional JSON object payload merged after --json") applyFlagSpecs(cmd, specs) return cmd } // canRegisterToolFlag reports whether a long flag named name can be // registered on cmd without panicking pflag ("flag redefined"). The reserved // payload names are excluded too: newToolCommand unconditionally registers // --json/--params before the spec loop. Tool schemas are remote data — a // property named after a reserved or already-registered flag must degrade to // "flag unavailable" (the value stays reachable through --json/--params), // never abort the process. Mirrors internal/compat's canRegisterFlag. func canRegisterToolFlag(cmd *cobra.Command, name string) bool { if name == "" || name == "json" || name == "params" { return false } return cmd.Flags().Lookup(name) == nil } // safeToolShorthand returns short when it is a single-character shorthand not // yet bound on cmd; otherwise "" (drop the shorthand, keep the long flag). // pflag panics on both multi-character and duplicate shorthands. func safeToolShorthand(cmd *cobra.Command, short string) string { short = strings.TrimSpace(short) if len(short) != 1 { return "" } if cmd.Flags().ShorthandLookup(short) != nil { return "" } return short } func applyFlagSpecs(cmd *cobra.Command, specs []FlagSpec) { for _, spec := range specs { usage := spec.Description if usage == "" { usage = fmt.Sprintf("Override %s", spec.PropertyName) } primary := strings.TrimSpace(spec.FlagName) if !canRegisterToolFlag(cmd, primary) { continue } shorthand := safeToolShorthand(cmd, spec.Shorthand) alias := strings.TrimSpace(spec.Alias) if alias == primary || !canRegisterToolFlag(cmd, alias) { alias = "" } switch spec.Kind { case flagString, flagJSON: cmd.Flags().StringP(primary, shorthand, "", usage) if alias != "" { cmd.Flags().String(alias, "", usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) } case flagInteger: cmd.Flags().IntP(primary, shorthand, 0, usage) if alias != "" { cmd.Flags().Int(alias, 0, usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) } case flagNumber: cmd.Flags().Float64P(primary, shorthand, 0, usage) if alias != "" { cmd.Flags().Float64(alias, 0, usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) } case flagBoolean: cmd.Flags().BoolP(primary, shorthand, false, usage) if alias != "" { cmd.Flags().Bool(alias, false, usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) } case flagStringArray, flagIntegerList, flagNumberList, flagBooleanList: cmd.Flags().StringSliceP(primary, shorthand, nil, usage) if alias != "" { cmd.Flags().StringSlice(alias, nil, usage+" (alias)") _ = cmd.Flags().MarkHidden(alias) } } } } func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) (map[string]any, error) { overrides := make(map[string]any) for _, spec := range specs { flagName := strings.TrimSpace(spec.FlagName) if alias := strings.TrimSpace(spec.Alias); alias != "" && cobracmd.FlagChanged(cmd, alias) { flagName = alias } flag := cmd.Flags().Lookup(flagName) if flag == nil || !flag.Changed { continue } switch spec.Kind { case flagString: value, err := cmd.Flags().GetString(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } // Resolve @file / @- for all string-typed flags. resolved, resolveErr := ResolveInputSource(value, flagName, guard) if resolveErr != nil { return nil, resolveErr } overrides[spec.PropertyName] = resolved case flagJSON: value, err := cmd.Flags().GetString(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } var parsed any if jsonErr := json.Unmarshal([]byte(value), &parsed); jsonErr != nil { return nil, apperrors.NewValidation(fmt.Sprintf("invalid JSON for --%s: %v", flagName, jsonErr)) } overrides[spec.PropertyName] = parsed case flagInteger: value, err := cmd.Flags().GetInt(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } overrides[spec.PropertyName] = value case flagNumber: value, err := cmd.Flags().GetFloat64(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } overrides[spec.PropertyName] = value case flagBoolean: value, err := cmd.Flags().GetBool(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } overrides[spec.PropertyName] = value case flagStringArray: value, err := cmd.Flags().GetStringSlice(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } overrides[spec.PropertyName] = convert.StringsToAny(value) case flagIntegerList: value, err := cmd.Flags().GetStringSlice(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } parsed, parseErr := convert.ParseStringList(value, strconv.Atoi) if parseErr != nil { return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr)) } overrides[spec.PropertyName] = convert.IntsToAny(parsed) case flagNumberList: value, err := cmd.Flags().GetStringSlice(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } parsed, parseErr := convert.ParseStringList(value, func(raw string) (float64, error) { return strconv.ParseFloat(raw, 64) }) if parseErr != nil { return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr)) } overrides[spec.PropertyName] = convert.FloatsToAny(parsed) case flagBooleanList: value, err := cmd.Flags().GetStringSlice(flagName) if err != nil { return nil, apperrors.NewInternal(fmt.Sprintf("failed to read --%s", flagName)) } parsed, parseErr := convert.ParseStringList(value, strconv.ParseBool) if parseErr != nil { return nil, apperrors.NewValidation(fmt.Sprintf("invalid values for --%s: %v", flagName, parseErr)) } overrides[spec.PropertyName] = convert.BoolsToAny(parsed) } } return overrides, nil } func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) { if len(args) == 0 { products := make([]map[string]any, 0, len(catalog.Products)) for _, p := range catalog.Products { tools := make([]map[string]any, 0, len(p.Tools)) for _, t := range p.Tools { tools = append(tools, compactTool(t)) } products = append(products, map[string]any{ "id": p.ID, "name": p.DisplayName, "description": p.Description, "tools": tools, }) } return map[string]any{ "kind": "schema", "count": len(products), "products": products, }, nil } product, tool, ok := resolveSchemaPath(catalog, args[0]) if !ok { return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0])) } return map[string]any{ "kind": "schema", "path": args[0], "product": map[string]any{"id": product.ID, "name": product.DisplayName}, "tool": compactTool(tool), }, nil } // resolveSchemaPath accepts three input forms and maps to (product, tool): // - "product.rpc_name" (canonical, e.g. "ding.send_ding_message") // - "product.cli_name" (single-level CLI path, e.g. "doc.create") // - CLI path with group ("ding message send" or "ding.message.send"; // also accepts "/" and multiple whitespace between tokens) // // Canonical form is tried first so existing callers and scripts keep // working; only when that fails does the CLI-path resolver run. func resolveSchemaPath(catalog ir.Catalog, raw string) (ir.CanonicalProduct, ir.ToolDescriptor, bool) { raw = strings.TrimSpace(raw) if raw == "" { return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false } if product, tool, ok := catalog.FindTool(raw); ok { return product, tool, true } tokens := splitSchemaPathTokens(raw) if len(tokens) < 2 { return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false } productID := tokens[0] leaf := tokens[len(tokens)-1] groupPath := strings.Join(tokens[1:len(tokens)-1], ".") product, ok := catalog.FindProduct(productID) if !ok { return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false } for _, tool := range product.Tools { if tool.CLIName != leaf { continue } if strings.TrimSpace(tool.Group) != groupPath { continue } return product, tool, true } return ir.CanonicalProduct{}, ir.ToolDescriptor{}, false } // splitSchemaPathTokens splits a CLI path on dots, slashes, and // whitespace, returning only non-empty tokens. "ding message send", // "ding.message.send", and "ding/message/send" all yield the same // three tokens. func splitSchemaPathTokens(raw string) []string { fields := strings.FieldsFunc(raw, func(r rune) bool { return r == '.' || r == '/' || r == ' ' || r == '\t' }) out := fields[:0] for _, f := range fields { if s := strings.TrimSpace(f); s != "" { out = append(out, s) } } return out } // compactTool returns a lean representation of a tool for schema // output, keeping the fields AI agents and scripts need: RPC + CLI // identity, input/output schema, sensitivity, MCP annotations, and the // CLI flag overlay (alias/transform/envDefault/default) that shapes // how raw MCP parameters appear on the command line. func compactTool(t ir.ToolDescriptor) map[string]any { tool := map[string]any{ "name": t.RPCName, "cli_name": t.CLIName, "canonical_path": t.CanonicalPath, "title": t.Title, "description": t.Description, "sensitive": t.Sensitive, } if strings.TrimSpace(t.Group) != "" { tool["group"] = t.Group } if props, ok := t.InputSchema["properties"]; ok { tool["parameters"] = props } if req := requiredFields(t.InputSchema); len(req) > 0 { tool["required"] = req } if len(t.OutputSchema) > 0 { tool["output_schema"] = t.OutputSchema } if t.Annotations != nil { tool["annotations"] = t.Annotations } if t.Auth != nil { tool["auth"] = t.Auth } if len(t.FlagOverlay) > 0 { tool["flag_overlay"] = t.FlagOverlay } return tool } func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error { if !tool.Sensitive { return nil } yes := false if cmd.Flags().Lookup("yes") != nil { value, err := cmd.Flags().GetBool("yes") if err != nil { return apperrors.NewInternal("failed to read --yes") } yes = value } if yes { return nil } // Stdin was consumed for data input — interactive confirmation is impossible. if guard != nil && guard.Claimed() { return apperrors.NewValidation( "stdin used for data input; pass --yes to confirm sensitive operation", ) } _, _ = fmt.Fprintf(cmd.ErrOrStderr(), "tool %s is sensitive, continue? [y/N]: ", tool.CanonicalPath) confirmed, err := readYesNo(cmd.InOrStdin()) if err != nil { return apperrors.NewInternal(fmt.Sprintf("failed to read confirmation input: %v", err)) } if !confirmed { return apperrors.NewValidation("sensitive operation cancelled; use --yes to skip confirmation") } return nil } func readYesNo(r io.Reader) (bool, error) { line, err := bufio.NewReader(r).ReadString('\n') if err != nil && !errors.Is(err, io.EOF) { return false, err } switch strings.ToLower(strings.TrimSpace(line)) { case "y", "yes": return true, nil default: return false, nil } } func lifecycleWarning(product ir.CanonicalProduct) string { if product.Lifecycle == nil { return "" } if product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" && !product.Lifecycle.DeprecatedCandidate { return "" } parts := make([]string, 0, 3) if product.Lifecycle.DeprecatedCandidate && product.Lifecycle.DeprecatedBy <= 0 && strings.TrimSpace(product.Lifecycle.DeprecationDate) == "" { parts = append(parts, fmt.Sprintf("product %s is marked as legacy candidate", product.ID)) } else { parts = append(parts, fmt.Sprintf("product %s is deprecated", product.ID)) } if product.Lifecycle.DeprecatedBy > 0 { parts = append(parts, fmt.Sprintf("deprecated_by_mcpId=%d", product.Lifecycle.DeprecatedBy)) } if strings.TrimSpace(product.Lifecycle.DeprecationDate) != "" { parts = append(parts, "deprecation_date="+strings.TrimSpace(product.Lifecycle.DeprecationDate)) } if strings.TrimSpace(product.Lifecycle.MigrationURL) != "" { parts = append(parts, "migration="+strings.TrimSpace(product.Lifecycle.MigrationURL)) } return strings.Join(parts, "; ") } func nestedMap(root map[string]any, key string) (map[string]any, bool) { if root == nil { return nil, false } value, ok := root[key] if !ok { return nil, false } out, ok := value.(map[string]any) return out, ok } func flagKindForSchema(schema map[string]any) (FlagKind, bool) { if _, ok := schema["enum"].([]any); ok { return flagString, true } switch schema["type"] { case "string": return flagString, true case "integer": return flagInteger, true case "number": return flagNumber, true case "boolean": return flagBoolean, true case "object": return flagJSON, true case "array": items, ok := schema["items"].(map[string]any) if !ok { return flagJSON, true } if _, ok := items["enum"].([]any); ok { return flagStringArray, true } switch items["type"] { case "string": return flagStringArray, true case "integer": return flagIntegerList, true case "number": return flagNumberList, true case "boolean": return flagBooleanList, true case "object": return flagJSON, true } } return "", false } func schemaDescription(schema map[string]any) string { value, _ := schema["description"].(string) return strings.TrimSpace(value) } func requiredFields(schema map[string]any) []string { raw, ok := schema["required"].([]any) if !ok { return nil } fields := make([]string, 0, len(raw)) for _, entry := range raw { value, ok := entry.(string) if ok && value != "" { fields = append(fields, value) } } return fields } func preferredProductRouteToken(product ir.CanonicalProduct) string { if product.CLI == nil { return "" } parts := splitRouteTokens(product.CLI.Command) if len(parts) == 0 { return "" } return parts[len(parts)-1] } func splitRouteTokens(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } segments := strings.FieldsFunc(raw, func(r rune) bool { return r == '/' || r == '\\' || r == '.' }) out := make([]string, 0, len(segments)) for _, segment := range segments { normalized := normalizeRouteToken(segment) if normalized == "" { continue } out = append(out, normalized) } return out } func normalizeRouteToken(raw string) string { raw = strings.TrimSpace(strings.ToLower(raw)) if raw == "" { return "" } var builder strings.Builder lastDash := false for _, r := range raw { switch { case r >= 'a' && r <= 'z': builder.WriteRune(r) lastDash = false case r >= '0' && r <= '9': builder.WriteRune(r) lastDash = false case r == '-' || r == '_' || r == ' ': if builder.Len() > 0 && !lastDash { builder.WriteByte('-') lastDash = true } } } return strings.Trim(builder.String(), "-") }