Skip to content
Closed
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
36 changes: 36 additions & 0 deletions pkg/cli/mcp_server_unit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
Expand Down Expand Up @@ -124,3 +125,38 @@ func TestMCPServerUnit_CompileTool(t *testing.T) {
assert.Equal(t, "compile", capturedArgs[0], "first arg should be 'compile'")
assert.Contains(t, strings.Join(capturedArgs, " "), "--json", "compile should pass --json flag")
}

func TestMCPServerUnit_CompileToolBulkTimeoutReturnsStructuredError(t *testing.T) {
originalTimeout := mcpCompileBulkTimeout
mcpCompileBulkTimeout = 100 * time.Millisecond
t.Cleanup(func() {
mcpCompileBulkTimeout = originalTimeout
})

mockExecCmd := func(ctx context.Context, args ...string) *exec.Cmd {
return exec.CommandContext(ctx, "sh", "-c", "sleep 1")
}

server := mcp.NewServer(&mcp.Implementation{Name: "gh-aw", Version: "test"}, nil)
require.NoError(t, registerCompileTool(server, mockExecCmd, ""), "registerCompileTool should succeed")
session := connectInMemory(t, server)

result, err := session.CallTool(context.Background(), &mcp.CallToolParams{
Name: "compile",
Arguments: map[string]any{},
})
require.NoError(t, err, "compile tool should return structured JSON, not protocol error")
require.NotNil(t, result, "compile tool should return a result")
require.NotEmpty(t, result.Content, "compile tool should return content")

textContent, ok := result.Content[0].(*mcp.TextContent)
require.True(t, ok, "compile tool should return text content")

var results []ValidationResult
require.NoError(t, json.Unmarshal([]byte(textContent.Text), &results), "result should be valid JSON")
require.Len(t, results, 1, "timeout should return a single structured result")
require.NotEmpty(t, results[0].Errors, "timeout result should include an error")
assert.Equal(t, "config_error", results[0].Errors[0].Type)
assert.Contains(t, results[0].Errors[0].Message, "timed out")
assert.Contains(t, results[0].Errors[0].Message, "workflows parameter")
}
32 changes: 31 additions & 1 deletion pkg/cli/mcp_tools_readonly.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,17 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"path/filepath"
"time"

"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

var mcpCompileBulkTimeout = 60 * time.Second

// statusArgs holds the input parameters for the status tool.
type statusArgs struct {
Pattern string `json:"pattern,omitempty" jsonschema:"Optional pattern to filter workflows by name"`
Expand Down Expand Up @@ -221,7 +225,14 @@ Returns JSON array with validation results for each workflow:
// Use separate stdout/stderr capture instead of CombinedOutput because:
// - Stdout contains JSON output (--json flag)
// - Stderr contains console messages that shouldn't be mixed with JSON
cmd := execCmd(ctx, cmdArgs...)
execCtx := ctx
if len(args.Workflows) == 0 {
var cancel context.CancelFunc
execCtx, cancel = context.WithTimeout(ctx, mcpCompileBulkTimeout)
defer cancel()
}

cmd := execCmd(execCtx, cmdArgs...)
stdout, err := cmd.Output()

// The compile command always outputs JSON to stdout when --json flag is used, even on error.
Expand All @@ -233,6 +244,25 @@ Returns JSON array with validation results for each workflow:
// which are included in the JSON output. Return the output, not an MCP error.
if err != nil {
mcpLog.Printf("Compile command exited with error: %v (output length: %d)", err, len(outputStr))
if len(args.Workflows) == 0 && errors.Is(execCtx.Err(), context.DeadlineExceeded) {
timeoutMsg := fmt.Sprintf(
"bulk compile timed out after %s; use the workflows parameter to compile in smaller batches",
mcpCompileBulkTimeout.String(),
)
timeoutResults := []ValidationResult{{
Workflow: "",
Valid: false,
Errors: []CompileValidationError{{Type: "config_error", Message: timeoutMsg}},
Warnings: []CompileValidationError{},
}}
jsonBytes, jsonErr := json.Marshal(timeoutResults)
if jsonErr != nil {
return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to marshal timeout results", jsonErr.Error())
}
return &mcp.CallToolResult{
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonBytes)}},
}, nil, nil
}
// If we have no output, this is a real execution failure
if len(outputStr) == 0 {
// Try to get stderr for error details
Expand Down