package server import ( "context" "encoding/json" "errors" "fmt" "maps" "os" "reflect" "strconv" "strings" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/scaleway/scaleway-cli/v2/core" "github.com/scaleway/scaleway-cli/v2/internal/args" "github.com/scaleway/scaleway-sdk-go/scw" "github.com/scaleway/scaleway-sdk-go/strcase" ) // CommandTool wraps a CLI command for MCP tool execution type CommandTool struct { Command *core.Command } // NewCommandTool creates a new CommandTool from a core.Command func NewCommandTool(cmd *core.Command) *CommandTool { return &CommandTool{ Command: cmd, } } // ToMCPTool converts the CommandTool to an MCP Tool func (ct *CommandTool) ToMCPTool() *mcp.Tool { tool := &mcp.Tool{ Title: CommandNameToToolName(ct.Command), Name: CommandNameToToolName(ct.Command), Description: ct.Command.Short, InputSchema: CommandToFlatArgsSchema(ct.Command), } // Add command metadata in description for debugging if ct.Command.Long != "" { tool.Description = ct.Command.Short + "\n\n" + truncateString(ct.Command.Long, 200) } // Add command metadata (namespace, resource, verb) to the tool's Meta field // This allows clients to access structured information about the command // https://modelcontextprotocol.io/specification/2025-11-25/basic#_meta tool.Meta = mcp.Meta{ "namespace": ct.Command.Namespace, "resource": ct.Command.Resource, "verb": ct.Command.Verb, } // https://modelcontextprotocol.io/specification/2025-11-25/server/resources#annotations tool.Annotations = &mcp.ToolAnnotations{ OpenWorldHint: new(true), // We interact with Scaleway for all registered commands DestructiveHint: new(ct.Command.IsDestructive()), IdempotentHint: ct.Command.IsIdempotent(), ReadOnlyHint: ct.Command.IsReadOnly(), } return tool } // Execute runs the CLI command with the provided arguments func (ct *CommandTool) Execute( ctx context.Context, inputArgs map[string]any, ) (*mcp.CallToolResult, error) { // Ensure meta is available in context // If meta is already in context (from wrapper), this is a no-op // If not, this creates meta from environment/config (useful for tests) ctx, err := ensureMetaInContext(ctx, nil) if err != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: fmt.Sprintf("Error initializing client: %v", err), }, }, IsError: true, }, nil } // Verify client was successfully injected if core.ExtractClient(ctx) == nil { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: "Error: client not initialized - check SCW credentials (access key, secret key, or profile)", }, }, IsError: true, }, nil } // Skip commands without a Run function if ct.Command.Run == nil { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: "Command has no execution logic", }, }, }, nil } // Create a new instance of the expected args type var cmdArgs any if ct.Command.ArgsType != nil { cmdArgs = reflect.New(ct.Command.ArgsType).Interface() // Convert map[string]any to []string format expected by args.UnmarshalStruct // Format: ["arg1=value1", "arg2=value2"] rawArgs := make([]string, 0, len(inputArgs)) for argName, argValue := range inputArgs { // Convert from kebab-case back to original arg spec name originalName := argName for _, spec := range ct.Command.ArgSpecs { if strcase.ToKebab(spec.Name) == argName { originalName = spec.Name break } } // Convert value to string var valueStr string switch v := argValue.(type) { case string: valueStr = v case bool: valueStr = strconv.FormatBool(v) case float64: valueStr = fmt.Sprintf("%v", v) case int: valueStr = strconv.Itoa(v) default: // For complex types, marshal to JSON if b, marshalErr := json.Marshal(v); marshalErr == nil { valueStr = string(b) } else { valueStr = fmt.Sprintf("%v", v) } } rawArgs = append(rawArgs, originalName+"="+valueStr) } // Apply default values for missing args (e.g. zone, region). // This mirrors the normal CLI execution path in cobra_utils.go. rawArgs = core.ApplyDefaultValues(ctx, ct.Command.ArgSpecs, args.RawArgs(rawArgs)) // Unmarshal the args into the struct if unmarshalErr := args.UnmarshalStruct(rawArgs, cmdArgs); unmarshalErr != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: fmt.Sprintf("Error parsing arguments: %v", unmarshalErr), }, }, IsError: true, }, unmarshalErr } } else { // Fallback for commands without ArgsType cmdArgs = inputArgs } // Execute the command's Run function using the interceptor chain // This ensures custom request wrappers (like customListServersRequest) are properly handled var result any var execErr error var panicRecovered bool var panicMessage string func() { defer func() { if r := recover(); r != nil { panicRecovered = true panicMessage = fmt.Sprintf("panic recovered during command execution: %v", r) } }() // Create a runner that wraps the command's Run function var runner core.CommandRunner = func(ctx context.Context, argsI any) (i any, err error) { return ct.Command.Run(ctx, argsI) } // Apply command interceptor if present if ct.Command.Interceptor != nil { result, execErr = ct.Command.Interceptor(ctx, cmdArgs, runner) } else { result, execErr = runner(ctx, cmdArgs) } }() // Handle panic recovery - return as error response but not as Go error if panicRecovered { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: panicMessage, }, }, IsError: true, }, nil } if execErr != nil { return &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: fmt.Sprintf("Error: %v", execErr), }, }, IsError: true, }, execErr } // Format the result var resultStr string if result != nil { // JSON marshals nil slices as "null". Convert them to empty slices // so list commands return "[]" instead of "null" when there are no results. v := reflect.ValueOf(result) if v.Kind() == reflect.Slice && v.IsNil() { result = reflect.MakeSlice(v.Type(), 0, 0).Interface() } if data, err := json.MarshalIndent(result, "", " "); err == nil { resultStr = string(data) } else { resultStr = fmt.Sprintf("%v", result) } } else { resultStr = "Command executed successfully (no output)" } callResult := &mcp.CallToolResult{ Content: []mcp.Content{ &mcp.TextContent{ Text: resultStr, }, }, } // Set StructuredContent to the raw result value so clients receive // proper JSON data instead of a JSON-encoded string. if result != nil { callResult.StructuredContent = result } return callResult, nil } // CommandNameToToolName converts a command to an MCP tool name func CommandNameToToolName(cmd *core.Command) string { parts := []string{} if cmd.Namespace != "" { parts = append(parts, cmd.Namespace) } if cmd.Resource != "" { parts = append(parts, cmd.Resource) } if cmd.Verb != "" { parts = append(parts, cmd.Verb) } // Join with underscores for MCP tool naming convention toolName := strings.Join(parts, "_") // Ensure tool name doesn't exceed MCP limits (typically 64 chars) if len(toolName) > 64 { toolName = toolName[:64] } return toolName } func truncateString(s string, maxLen int) string { if len(s) <= maxLen { return s } return s[:maxLen-3] + "..." } // ensureMetaInContext ensures meta is available in the context for command execution. // If meta is already present, it returns the context unchanged. // If meta is provided, it injects it into the context. // If no meta is provided, it creates one from environment/config. // Returns the updated context and any error that occurred. func ensureMetaInContext(ctx context.Context, meta *core.Meta) (context.Context, error) { // Meta already in context - nothing to do if core.ExtractMeta(ctx) != nil { return ctx, nil } // Use provided meta if meta != nil { m := *meta m.OverrideEnv = make(map[string]string) maps.Copy(m.OverrideEnv, meta.OverrideEnv) return core.InjectMeta(ctx, &m), nil } // No meta provided - create one from environment/config configPath := scw.GetConfigPath() config, err := scw.LoadConfigFromPath(configPath) if err != nil && !errors.Is(err, os.ErrNotExist) { return ctx, fmt.Errorf("loading config: %w", err) } profileName := scw.DefaultProfileName if config != nil && config.ActiveProfile != nil { profileName = *config.ActiveProfile } if envProfile := os.Getenv(scw.ScwActiveProfileEnv); envProfile != "" { profileName = envProfile } options := []scw.ClientOption{ scw.WithDefaultRegion(scw.RegionFrPar), scw.WithDefaultZone(scw.ZoneFrPar1), scw.WithUserAgent("scaleway-mcp-server"), scw.WithEnv(), } if config != nil { profile, err := config.GetProfile(profileName) if err != nil { return ctx, fmt.Errorf("getting profile %q: %w", profileName, err) } if profile != nil { options = append(options, scw.WithProfile(profile)) } } client, err := scw.NewClient(options...) if err != nil { return ctx, fmt.Errorf("creating client: %w", err) } return core.InjectMeta(ctx, &core.Meta{ Client: client, OverrideEnv: map[string]string{}, BinaryName: "scw-mcp", }), nil }