diff --git a/internal/app/root.go b/internal/app/root.go index 509f6ea3..ef2b03b8 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -189,6 +189,9 @@ func NewRootCommandWithEngine(rootCtx context.Context, engine *pipeline.Engine) loader := cli.EnvironmentLoader{ LookupEnv: os.LookupEnv, CatalogBaseURLOverride: DiscoveryBaseURL(), + AuthTokenFunc: func(ctx context.Context) string { + return resolveRuntimeAuthToken(ctx, "") + }, } runner := newCommandRunnerWithFlags(loader, flags) diff --git a/internal/app/runner.go b/internal/app/runner.go index 6c294ecc..32593d6e 100644 --- a/internal/app/runner.go +++ b/internal/app/runner.go @@ -171,6 +171,15 @@ func (r *runtimeRunner) executeInvocation(ctx context.Context, endpoint string, return executor.Result{}, err } + if bizErr := detectBusinessError(callResult.Content); bizErr != "" { + return executor.Result{}, apperrors.NewAPI(bizErr, + apperrors.WithOperation("tools/call"), + apperrors.WithReason("business_error"), + apperrors.WithServerKey(invocation.CanonicalProduct), + apperrors.WithHint("The API returned a business-level error. Check required parameters and values."), + ) + } + invocation.Implemented = true response := map[string]any{ "endpoint": transport.RedactURL(endpoint), @@ -279,6 +288,27 @@ func resolveIdentityHeaders() map[string]string { return headers } +// detectBusinessError checks the MCP response content for DingTalk business +// errors (success=false + errorCode/errorMsg) that are not flagged at the MCP +// protocol level. Returns the error message, or "" if the response is OK. +func detectBusinessError(content map[string]any) string { + success, ok := content["success"] + if !ok { + return "" + } + b, ok := success.(bool) + if !ok || b { + return "" + } + if msg, ok := content["errorMsg"].(string); ok && strings.TrimSpace(msg) != "" { + return strings.TrimSpace(msg) + } + if code, ok := content["errorCode"].(string); ok && strings.TrimSpace(code) != "" { + return "business error: code " + strings.TrimSpace(code) + } + return "business error: success=false" +} + // extractMCPErrorMessage builds an error message from a ToolCallResult with // isError=true. It extracts text from content blocks when available. func extractMCPErrorMessage(result transport.ToolCallResult) string { diff --git a/internal/cli/canonical.go b/internal/cli/canonical.go index 8b39b221..01e1fc41 100644 --- a/internal/cli/canonical.go +++ b/internal/cli/canonical.go @@ -585,10 +585,23 @@ func collectOverrides(cmd *cobra.Command, specs []FlagSpec, guard *StdinGuard) ( 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", - "products": catalog.Products, - "count": len(catalog.Products), + "count": len(products), + "products": products, }, nil } @@ -597,14 +610,34 @@ func schemaPayload(catalog ir.Catalog, args []string) (map[string]any, error) { return nil, apperrors.NewValidation(fmt.Sprintf("unknown canonical schema path %q", args[0])) } return map[string]any{ - "kind": "schema", - "path": args[0], - "product": product, - "tool": tool, - "required": requiredFields(tool.InputSchema), + "kind": "schema", + "path": args[0], + "product": map[string]any{"id": product.ID, "name": product.DisplayName}, + "tool": compactTool(tool), }, nil } +// compactTool returns a lean representation of a tool for schema +// output, keeping only the fields that AI agents and developers +// need: name, description, parameters, and sensitivity flag. +func compactTool(t ir.ToolDescriptor) map[string]any { + tool := map[string]any{ + "name": t.RPCName, + "title": t.Title, + "description": t.Description, + "sensitive": t.Sensitive, + } + + if props, ok := t.InputSchema["properties"]; ok { + tool["parameters"] = props + } + if req := requiredFields(t.InputSchema); len(req) > 0 { + tool["required"] = req + } + + return tool +} + func confirmSensitiveTool(cmd *cobra.Command, tool ir.ToolDescriptor, guard *StdinGuard) error { if !tool.Sensitive { return nil diff --git a/internal/cli/loader.go b/internal/cli/loader.go index 8f6f094e..c05439b8 100644 --- a/internal/cli/loader.go +++ b/internal/cli/loader.go @@ -89,6 +89,9 @@ type EnvironmentLoader struct { // DiscoveryTimeout overrides the default timeout for live registry discovery. // Zero means use defaultDiscoveryTimeout. DiscoveryTimeout time.Duration + // AuthTokenFunc returns an access token for MCP discovery requests + // (initialize, tools/list). When nil, discovery runs without auth. + AuthTokenFunc func(context.Context) string } type cachedCatalogState struct { @@ -125,6 +128,11 @@ func (l EnvironmentLoader) Load(ctx context.Context) (ir.Catalog, error) { } transportClient := transport.NewClient(nil) + if l.AuthTokenFunc != nil { + if token := l.AuthTokenFunc(ctx); token != "" { + transportClient = transportClient.WithAuth(token, nil) + } + } // Use a bounded context so discovery doesn't hang in test or CI environments. timeout := defaultDiscoveryTimeout diff --git a/internal/helpers/todo.go b/internal/helpers/todo.go index 919c03ed..c87320ef 100644 --- a/internal/helpers/todo.go +++ b/internal/helpers/todo.go @@ -88,6 +88,7 @@ func newTodoTaskListCommand(runner executor.Runner) *cobra.Command { ) if size <= todoListPageSizeMax { + invocation.DryRun = commandDryRun(cmd) result, err := runner.Run(cmd.Context(), invocation) if err != nil { return err