Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/component/changed.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ detected via lock file presence in the compared refs when using -a.`,
// it inspects historical locks at arbitrary refs.
_ = cmd.Flags().MarkHidden("skip-lock-validation")

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/component/history.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ hand-picking entries to document.`,
// History is read-only; the lock validation flag is meaningless here.
_ = cmd.Flags().MarkHidden("skip-lock-validation")

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/component/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ Component name patterns support glob syntax (*, ?, []).`,
ValidArgsFunction: components.GenerateComponentNameCompletions,
}

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

components.AddComponentFilterOptionsToCommand(cmd, &options.ComponentFilter)

Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/config/dump.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ issues or inspecting effective values.`,

cmd.Flags().VarP(&configDumpFormat, "format", "f", "Output format {json, toml}")

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/config/schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ editors or linters that support JSON Schema validation of TOML files.`,
},
}

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/image/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ If no patterns are provided, all images are listed.`,
ValidArgsFunction: generateImageNameCompletions,
}

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
2 changes: 1 addition & 1 deletion internal/app/azldev/cmds/pkg/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ and are emitted as empty arrays (never null) when there are no memberships.`,
// Help shells complete '--rpm-file' with .json paths.
_ = cmd.MarkFlagFilename("rpm-file", "json")

azldev.ExportAsMCPTool(cmd)
azldev.ExportAsReadOnlyMCPTool(cmd)

return cmd
}
Expand Down
34 changes: 32 additions & 2 deletions internal/app/azldev/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,15 @@ var ErrInvalidUsage = errors.New("invalid usage")
// should be enabled in MCP server mode. The value associated with the key is ignored.
const CmdAnnotationMCPEnabled = "azldev.mcp.enabled"

// CmdAnnotationMCPReadOnly is the [cobra.Command] annotation key used to indicate that a command,
// when exposed as an MCP tool, is read-only (does not mutate any state). MCP clients may use this
// hint to auto-approve the tool. The value associated with the key is ignored.
const CmdAnnotationMCPReadOnly = "azldev.mcp.readonly"

// cmdMCPAnnotationValue is the placeholder value stored for MCP command annotations; only the
// presence of the key matters.
const cmdMCPAnnotationValue = "true"

type (
cobraRunFuncType = func(command *cobra.Command, args []string) error

Expand Down Expand Up @@ -147,21 +156,42 @@ func GetEnvFromCommand(cmd *cobra.Command) (*Env, error) {
}

// ExportAsMCPTool updates the provided command (and all descendant commands),
// opting it into being advertised as a tool in MCP server mode.
// opting it into being advertised as a tool in MCP server mode. Use it for commands
// that can write, but only ever to a caller-provided output path (e.g. 'docs markdown',
// 'component diff-sources --output-file'); use [ExportAsReadOnlyMCPTool] for commands
// that never modify their environment so clients can auto-approve them.
func ExportAsMCPTool(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}

// The value doesn't matter.
cmd.Annotations[CmdAnnotationMCPEnabled] = "true"
cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue

// If the command has subcommands, then recursively opt them in as well.
for _, subCmd := range cmd.Commands() {
ExportAsMCPTool(subCmd)
}
}

// ExportAsReadOnlyMCPTool is like [ExportAsMCPTool], but additionally marks the command (and all
// descendant commands) as read-only -- it does not modify its environment -- so that MCP clients
// can advertise and auto-approve them as non-mutating tools.
func ExportAsReadOnlyMCPTool(cmd *cobra.Command) {
if cmd.Annotations == nil {
cmd.Annotations = make(map[string]string)
}

// The values don't matter.
cmd.Annotations[CmdAnnotationMCPEnabled] = cmdMCPAnnotationValue
cmd.Annotations[CmdAnnotationMCPReadOnly] = cmdMCPAnnotationValue

// If the command has subcommands, then recursively opt them in as well.
for _, subCmd := range cmd.Commands() {
ExportAsReadOnlyMCPTool(subCmd)
}
}

// Displays the results of a command in the appropriate format to stdout.
func reportResults(env *Env, results interface{}) error {
switch env.defaultReportFormat {
Expand Down
165 changes: 149 additions & 16 deletions internal/app/azldev/core/mcp/mcpserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,13 @@ import (
"github.com/samber/lo"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"go.szostok.io/version"
)

// Performs the file download requested by options.
// RunMCPServer starts the azldev MCP server, advertising the exported commands as
// tools over stdio until the context is canceled.
func RunMCPServer(env *azldev.Env, cmd *cobra.Command) error {
srv := server.NewMCPServer(cmd.Short, "1.0.0",
srv := server.NewMCPServer(cmd.Root().Name(), version.Get().Version,
server.WithLogging(),
server.WithRecovery(),
server.WithResourceCapabilities(true, true),
Expand All @@ -47,6 +49,13 @@ func RunMCPServer(env *azldev.Env, cmd *cobra.Command) error {

stdioServer := server.NewStdioServer(srv)

// handleToolCall runs the shared cobra command tree and swaps the process-global
// os.Stdout to capture output, so two handlers must not run at once. mcp-go
// dispatches tool calls to a worker pool (default 5) -- an agent firing parallel
// skill/tool calls would hit it -- so pin the pool to a single worker and let calls
// serialize instead of corrupting each other's output.
server.WithWorkerPoolSize(1)(stdioServer)

slog.Info("Starting MCP server")

// Run the server until canceled.
Expand Down Expand Up @@ -79,6 +88,12 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command)

toolOptions = append(toolOptions, mcp.WithDescription(toolDesc))

// Mirror our read-only annotation (set by [azldev.ExportAsReadOnlyMCPTool]) into the MCP tool
// schema so that clients can treat and auto-approve the tool as non-mutating.
if _, readOnly := leaf.Annotations[azldev.CmdAnnotationMCPReadOnly]; readOnly {
toolOptions = append(toolOptions, mcp.WithReadOnlyHintAnnotation(true))
}
Comment thread
dmcilvaney marked this conversation as resolved.

flags := getAllFlagDefs(leaf)
for _, flag := range flags {
var propOptions []mcp.PropertyOption
Expand Down Expand Up @@ -132,6 +147,8 @@ func addToolForCmd(env *azldev.Env, srv *server.MCPServer, leaf *cobra.Command)
func handleToolCall(
env *azldev.Env, cmd *cobra.Command,
) func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
flagDefaults := captureFlagDefaults(getAllFlagDefs(cmd))

return func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
slog.Info("Invoking tool", "tool", cmd.Name(), "params", request.Params.Arguments)

Expand All @@ -148,6 +165,10 @@ func handleToolCall(
}, nil
}

if err := restoreFlagDefaults(flagDefaults, args); err != nil {
return nil, fmt.Errorf("failed to reset command flags:\n%w", err)
}

for key, val := range args {
if key == "args" {
continue
Expand All @@ -165,32 +186,144 @@ func handleToolCall(
slog.Info("Executing command", "args", fullArgs)
cmd.Root().SetArgs(fullArgs)

reader, writer, err := os.Pipe()
if err != nil {
return nil, fmt.Errorf("failed to create pipe for command output:\n%w", err)
// LIMITATION: the [azldev.Env] here is created once when the MCP server starts and is reused
// for every tool call. Per-call global flags parsed by this Execute (e.g. '--dry-run',
Comment thread
dmcilvaney marked this conversation as resolved.
// '--project') update the App's flag fields but are NOT re-threaded into this reused Env, so
// env.DryRun() and the loaded config still reflect the server's startup values. As a result a
// mutating tool invoked with '--dry-run=true' would still write. Until the MCP rework gives
// each call its own Env, only expose commands that do not modify managed project state
// (specs, locks, config) on their own: read-only commands (marked with
// [azldev.ExportAsReadOnlyMCPTool]), or ones that write solely to a caller-provided output
// path, such as 'docs markdown' or 'component diff-sources --output-file'.
Comment on lines +195 to +197
capturedText, execErr := captureStdout(func() error {
env.SetReportFile(os.Stdout) // os.Stdout is the capture pipe for the duration of this call

return cmd.Root().Execute()
})
Comment on lines +198 to +202
if execErr != nil {
slog.Error("Error executing command", "error", execErr)

errorText := capturedText
if errorText != "" && !strings.HasSuffix(errorText, "\n") {
errorText += "\n"
}

errorText += execErr.Error()

result := mcp.NewToolResultText(errorText)
result.IsError = true

return result, nil
}

return mcp.NewToolResultText(capturedText), nil
}
}

type flagDefault struct {
flag *pflag.Flag
value string
sliceValues []string
changed bool
}

func (value flagDefault) restore(supplied bool) error {
if sliceValue, ok := value.flag.Value.(pflag.SliceValue); ok {
sliceValues := value.sliceValues
if supplied {
sliceValues = nil
}

origStdout := os.Stdout
os.Stdout = writer
if err := sliceValue.Replace(sliceValues); err != nil {
return fmt.Errorf("failed to restore slice value:\n%w", err)
}

env.SetReportFile(writer)
return nil
}

err = cmd.Root().Execute()
if err := value.flag.Value.Set(value.value); err != nil {
return fmt.Errorf("failed to restore value:\n%w", err)
}

os.Stdout = origStdout
return nil
}

if err != nil {
slog.Error("Error executing command", "error", err)
func captureFlagDefaults(flags []*pflag.Flag) []flagDefault {
defaults := make([]flagDefault, 0, len(flags))

return mcp.NewToolResultText(err.Error()), nil
for _, flag := range flags {
value := flagDefault{
flag: flag,
value: flag.Value.String(),
changed: flag.Changed,
}
if sliceValue, ok := flag.Value.(pflag.SliceValue); ok {
value.sliceValues = append([]string(nil), sliceValue.GetSlice()...)
}

defaults = append(defaults, value)
}

return defaults
}

func restoreFlagDefaults(defaults []flagDefault, args map[string]any) error {
for _, value := range defaults {
_, supplied := args[value.flag.Name]
if err := value.restore(supplied); err != nil {
return fmt.Errorf("failed to reset flag %#q:\n%w", value.flag.Name, err)
}

writer.Close()
value.flag.Changed = value.changed
}

capturedText, _ := io.ReadAll(reader)
return nil
}

return mcp.NewToolResultText(string(capturedText)), nil
// captureStdout runs action with os.Stdout redirected to a pipe and returns everything
// written to it. The pipe is drained by a concurrent goroutine so that output larger
// than the OS pipe buffer (~64KB, e.g. 'config dump' on a large distro) does not block
// the write and hang the caller. Not safe for concurrent use: it mutates the global
// os.Stdout, so callers must serialize (the MCP server pins its worker pool to one).
func captureStdout(action func() error) (string, error) {
reader, writer, err := os.Pipe()
if err != nil {
return "", fmt.Errorf("failed to create pipe for command output:\n%w", err)
}

origStdout := os.Stdout
os.Stdout = writer

captured := make(chan []byte, 1)

Comment thread
dmcilvaney marked this conversation as resolved.
go func() {
data, _ := io.ReadAll(reader)
captured <- data
}()

cleanedUp := false
cleanup := func() string {
if cleanedUp {
return ""
}

cleanedUp = true
os.Stdout = origStdout
_ = writer.Close() // signal EOF so the drain goroutine finishes
output := <-captured
_ = reader.Close()

return string(output)
}

defer func() {
_ = cleanup()
}()

actionErr := action()
output := cleanup()

return output, actionErr
}

func getLeafCommands(cmd *cobra.Command) []*cobra.Command {
Expand Down
Loading
Loading