Skip to content

Commit 35bb7df

Browse files
authored
Auto-detect and inspect safe-inputs in MCP inspect with Go SDK (#5709)
1 parent 1ad342b commit 35bb7df

3 files changed

Lines changed: 284 additions & 72 deletions

File tree

.github/workflows/release.lock.yml

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/cli/mcp_inspect.go

Lines changed: 149 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import (
99
"path/filepath"
1010
"strings"
1111
"sync"
12-
"syscall"
1312
"time"
1413

1514
"github.com/githubnext/gh-aw/pkg/console"
@@ -135,38 +134,37 @@ func InspectWorkflowMCP(workflowFile string, serverFilter string, toolFilter str
135134
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Inspecting MCP servers in: %s", workflowPath)))
136135
}
137136

138-
// Parse the workflow file
137+
// Parse the workflow file for MCP configurations
139138
content, err := os.ReadFile(workflowPath)
140139
if err != nil {
141140
return fmt.Errorf("failed to read workflow file: %w", err)
142141
}
143142

144-
workflowData, err := parser.ExtractFrontmatterFromContent(string(content))
143+
parsedData, err := parser.ExtractFrontmatterFromContent(string(content))
145144
if err != nil {
146145
return fmt.Errorf("failed to parse workflow file: %w", err)
147146
}
148147

149148
// Validate frontmatter before analyzing MCPs
150-
if err := parser.ValidateMainWorkflowFrontmatterWithSchemaAndLocation(workflowData.Frontmatter, workflowPath); err != nil {
149+
if err := parser.ValidateMainWorkflowFrontmatterWithSchemaAndLocation(parsedData.Frontmatter, workflowPath); err != nil {
151150
if verbose {
152151
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Frontmatter validation failed: %v", err)))
153152
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Continuing with MCP inspection (validation errors may affect results)"))
154-
} else {
155-
return fmt.Errorf("frontmatter validation failed: %w", err)
156153
}
154+
// Don't return error - continue with inspection even if validation fails
157155
} else if verbose {
158156
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Frontmatter validation passed"))
159157
}
160158

161159
// Process imports from frontmatter to merge imported MCP servers
162160
markdownDir := filepath.Dir(workflowPath)
163-
importsResult, err := parser.ProcessImportsFromFrontmatterWithManifest(workflowData.Frontmatter, markdownDir, nil)
161+
importsResult, err := parser.ProcessImportsFromFrontmatterWithManifest(parsedData.Frontmatter, markdownDir, nil)
164162
if err != nil {
165163
return fmt.Errorf("failed to process imports from frontmatter: %w", err)
166164
}
167165

168166
// Apply imported MCP servers to frontmatter
169-
frontmatterWithImports, err := applyImportsToFrontmatter(workflowData.Frontmatter, importsResult)
167+
frontmatterWithImports, err := applyImportsToFrontmatter(parsedData.Frontmatter, importsResult)
170168
if err != nil {
171169
return fmt.Errorf("failed to apply imports: %w", err)
172170
}
@@ -196,6 +194,56 @@ func InspectWorkflowMCP(workflowFile string, serverFilter string, toolFilter str
196194
// Filter out safe-outputs MCP servers for inspection
197195
mcpConfigs = filterOutSafeOutputs(mcpConfigs)
198196

197+
// Check if safe-inputs are present in the workflow by parsing with the compiler
198+
// (the compiler resolves imports and merges safe-inputs)
199+
compiler := workflow.NewCompiler(verbose, "", "")
200+
workflowData, err := compiler.ParseWorkflowFile(workflowPath)
201+
if err != nil {
202+
if verbose {
203+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to parse workflow for safe-inputs: %v", err)))
204+
}
205+
}
206+
207+
// Start safe-inputs server if present
208+
var safeInputsServerCmd *exec.Cmd
209+
var safeInputsTmpDir string
210+
if workflowData != nil && workflowData.SafeInputs != nil && len(workflowData.SafeInputs.Tools) > 0 {
211+
// Start safe-inputs server and add it to the list of MCP configs
212+
config, serverCmd, tmpDir, err := startSafeInputsServer(workflowData.SafeInputs, verbose)
213+
if err != nil {
214+
if verbose {
215+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to start safe-inputs server: %v", err)))
216+
}
217+
} else {
218+
safeInputsServerCmd = serverCmd
219+
safeInputsTmpDir = tmpDir
220+
// Add safe-inputs config to the list of MCP servers to inspect
221+
mcpConfigs = append(mcpConfigs, *config)
222+
}
223+
}
224+
225+
// Cleanup safe-inputs server when done
226+
if safeInputsServerCmd != nil {
227+
defer func() {
228+
if safeInputsServerCmd.Process != nil {
229+
// Try graceful shutdown first
230+
if err := safeInputsServerCmd.Process.Signal(os.Interrupt); err != nil && verbose {
231+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to send interrupt signal: %v", err)))
232+
}
233+
// Wait a moment for graceful shutdown
234+
time.Sleep(500 * time.Millisecond)
235+
// Attempt force kill (may fail if process already exited gracefully, which is fine)
236+
_ = safeInputsServerCmd.Process.Kill()
237+
}
238+
// Cleanup temporary directory
239+
if safeInputsTmpDir != "" {
240+
if err := os.RemoveAll(safeInputsTmpDir); err != nil && verbose {
241+
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to cleanup temporary directory: %v", err)))
242+
}
243+
}
244+
}()
245+
}
246+
199247
if len(mcpConfigs) == 0 {
200248
if serverFilter != "" {
201249
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("No MCP servers matching filter '%s' found in workflow", serverFilter)))
@@ -422,16 +470,83 @@ func waitForServerReady(port int, timeout time.Duration, verbose bool) bool {
422470
return false
423471
}
424472

473+
// startSafeInputsServer starts the safe-inputs HTTP server and returns the MCP config
474+
func startSafeInputsServer(safeInputsConfig *workflow.SafeInputsConfig, verbose bool) (*parser.MCPServerConfig, *exec.Cmd, string, error) {
475+
mcpInspectLog.Printf("Starting safe-inputs server with %d tools", len(safeInputsConfig.Tools))
476+
477+
// Check if node is available
478+
if _, err := exec.LookPath("node"); err != nil {
479+
return nil, nil, "", fmt.Errorf("node not found. Please install Node.js to run the safe-inputs MCP server: %w", err)
480+
}
481+
482+
if verbose {
483+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Found %d safe-input tool(s) to configure", len(safeInputsConfig.Tools))))
484+
}
485+
486+
// Create temporary directory for safe-inputs files
487+
tmpDir, err := os.MkdirTemp("", "gh-aw-safe-inputs-*")
488+
if err != nil {
489+
return nil, nil, "", fmt.Errorf("failed to create temporary directory: %w", err)
490+
}
491+
492+
if verbose {
493+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Created temporary directory: %s", tmpDir)))
494+
}
495+
496+
// Write safe-inputs files to temporary directory
497+
if err := writeSafeInputsFiles(tmpDir, safeInputsConfig, verbose); err != nil {
498+
os.RemoveAll(tmpDir)
499+
return nil, nil, "", fmt.Errorf("failed to write safe-inputs files: %w", err)
500+
}
501+
502+
// Find an available port for the HTTP server
503+
port := findAvailablePort(safeInputsStartPort, verbose)
504+
if port == 0 {
505+
os.RemoveAll(tmpDir)
506+
return nil, nil, "", fmt.Errorf("failed to find an available port for the HTTP server")
507+
}
508+
509+
if verbose {
510+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Using port %d for safe-inputs HTTP server", port)))
511+
}
512+
513+
// Start the HTTP server
514+
serverCmd, err := startSafeInputsHTTPServer(tmpDir, port, verbose)
515+
if err != nil {
516+
os.RemoveAll(tmpDir)
517+
return nil, nil, "", fmt.Errorf("failed to start safe-inputs HTTP server: %w", err)
518+
}
519+
520+
// Wait for the server to start up
521+
if !waitForServerReady(port, 5*time.Second, verbose) {
522+
if serverCmd.Process != nil {
523+
_ = serverCmd.Process.Kill()
524+
}
525+
os.RemoveAll(tmpDir)
526+
return nil, nil, "", fmt.Errorf("safe-inputs HTTP server failed to start within timeout")
527+
}
528+
529+
if verbose {
530+
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Safe-inputs HTTP server started successfully"))
531+
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Server running on: http://localhost:%d", port)))
532+
}
533+
534+
// Create MCP server config for the safe-inputs server
535+
config := &parser.MCPServerConfig{
536+
Name: "safeinputs",
537+
Type: "http",
538+
URL: fmt.Sprintf("http://localhost:%d", port),
539+
Env: make(map[string]string),
540+
}
541+
542+
return config, serverCmd, tmpDir, nil
543+
}
544+
425545
// spawnSafeInputsInspector generates safe-inputs MCP server files, starts the HTTP server,
426546
// and launches the inspector to inspect it
427547
func spawnSafeInputsInspector(workflowFile string, verbose bool) error {
428548
mcpInspectLog.Printf("Spawning safe-inputs inspector for workflow: %s", workflowFile)
429549

430-
// Check if npx is available
431-
if _, err := exec.LookPath("npx"); err != nil {
432-
return fmt.Errorf("npx not found. Please install Node.js and npm to use the MCP inspector: %w", err)
433-
}
434-
435550
// Check if node is available
436551
if _, err := exec.LookPath("node"); err != nil {
437552
return fmt.Errorf("node not found. Please install Node.js to run the safe-inputs MCP server: %w", err)
@@ -456,19 +571,17 @@ func spawnSafeInputsInspector(workflowFile string, verbose bool) error {
456571
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Inspecting safe-inputs from: %s", workflowPath)))
457572
}
458573

459-
// Parse the workflow file
460-
content, err := os.ReadFile(workflowPath)
461-
if err != nil {
462-
return fmt.Errorf("failed to read workflow file: %w", err)
463-
}
464-
465-
workflowData, err := parser.ExtractFrontmatterFromContent(string(content))
574+
// Use the workflow compiler to parse the file and resolve imports
575+
// This ensures that imported safe-inputs are properly merged
576+
compiler := workflow.NewCompiler(verbose, "", "")
577+
workflowData, err := compiler.ParseWorkflowFile(workflowPath)
466578
if err != nil {
467579
return fmt.Errorf("failed to parse workflow file: %w", err)
468580
}
469581

470-
// Extract safe-inputs configuration
471-
safeInputsConfig := workflow.ParseSafeInputs(workflowData.Frontmatter)
582+
// Get safe-inputs configuration from the parsed WorkflowData
583+
// This includes both direct and imported safe-inputs configurations
584+
safeInputsConfig := workflowData.SafeInputs
472585
if safeInputsConfig == nil || len(safeInputsConfig.Tools) == 0 {
473586
return fmt.Errorf("no safe-inputs configuration found in workflow")
474587
}
@@ -518,14 +631,8 @@ func spawnSafeInputsInspector(workflowFile string, verbose bool) error {
518631
}
519632
// Wait a moment for graceful shutdown
520633
time.Sleep(500 * time.Millisecond)
521-
// Check if process is still running before force kill
522-
// On Unix, sending signal 0 checks if process exists without killing it
523-
if err := serverCmd.Process.Signal(os.Signal(syscall.Signal(0))); err == nil {
524-
// Process still running, force kill
525-
if err := serverCmd.Process.Kill(); err != nil && verbose {
526-
fmt.Fprintln(os.Stderr, console.FormatWarningMessage(fmt.Sprintf("Failed to kill server process: %v", err)))
527-
}
528-
}
634+
// Attempt force kill (may fail if process already exited gracefully, which is fine)
635+
_ = serverCmd.Process.Kill()
529636
}
530637
}()
531638

@@ -537,21 +644,17 @@ func spawnSafeInputsInspector(workflowFile string, verbose bool) error {
537644
fmt.Fprintln(os.Stderr, console.FormatSuccessMessage("Safe-inputs HTTP server started successfully"))
538645
fmt.Fprintln(os.Stderr, console.FormatInfoMessage(fmt.Sprintf("Server running on: http://localhost:%d", port)))
539646
fmt.Println()
540-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Configure the MCP inspector with the following settings:"))
541-
fmt.Fprintf(os.Stderr, " Type: HTTP\n")
542-
fmt.Fprintf(os.Stderr, " URL: http://localhost:%d\n", port)
543-
fmt.Println()
544647

545-
// Launch the inspector
546-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Launching @modelcontextprotocol/inspector..."))
547-
fmt.Fprintln(os.Stderr, console.FormatInfoMessage("Visit http://localhost:5173 after the inspector starts"))
548-
549-
inspectorCmd := exec.Command("npx", "@modelcontextprotocol/inspector")
550-
inspectorCmd.Stdout = os.Stdout
551-
inspectorCmd.Stderr = os.Stderr
552-
inspectorCmd.Stdin = os.Stdin
648+
// Create MCP server config for the safe-inputs server
649+
safeInputsMCPConfig := parser.MCPServerConfig{
650+
Name: "safeinputs",
651+
Type: "http",
652+
URL: fmt.Sprintf("http://localhost:%d", port),
653+
Env: make(map[string]string),
654+
}
553655

554-
return inspectorCmd.Run()
656+
// Inspect the safe-inputs MCP server using the Go SDK (like other MCP servers)
657+
return inspectMCPServer(safeInputsMCPConfig, "", verbose, false)
555658
}
556659

557660
// spawnMCPInspector launches the official @modelcontextprotocol/inspector tool
@@ -769,7 +872,6 @@ func NewMCPInspectSubcommand() *cobra.Command {
769872
var toolFilter string
770873
var spawnInspector bool
771874
var checkSecrets bool
772-
var safeInputs bool
773875

774876
cmd := &cobra.Command{
775877
Use: "inspect [workflow-id-or-file]",
@@ -779,6 +881,8 @@ func NewMCPInspectSubcommand() *cobra.Command {
779881
This command starts each MCP server configured in the workflow, queries its capabilities,
780882
and displays the results in a formatted table. It supports stdio, Docker, and HTTP MCP servers.
781883
884+
Safe-inputs servers are automatically detected and inspected when present in the workflow.
885+
782886
The workflow-id-or-file can be:
783887
- A workflow ID (basename without .md extension, e.g., "weekly-research")
784888
- A file path (e.g., "weekly-research.md" or ".github/workflows/weekly-research.md")
@@ -791,11 +895,11 @@ Examples:
791895
gh aw mcp inspect weekly-research -v # Verbose output with detailed connection info
792896
gh aw mcp inspect weekly-research --inspector # Launch @modelcontextprotocol/inspector
793897
gh aw mcp inspect weekly-research --check-secrets # Check GitHub Actions secrets
794-
gh aw mcp inspect weekly-research --safe-inputs # Inspect safe-inputs MCP server with inspector
795898
796899
The command will:
797900
- Parse the workflow file to extract MCP server configurations
798901
- Start each MCP server (stdio, docker, http)
902+
- Automatically start and inspect safe-inputs server if present
799903
- Query available tools, resources, and roots
800904
- Validate required secrets are available
801905
- Display results in formatted tables with error details`,
@@ -824,20 +928,6 @@ The command will:
824928
return fmt.Errorf("--tool flag requires --server flag to be specified")
825929
}
826930

827-
// Validate that safe-inputs and inspector flags are mutually exclusive with other flags
828-
if safeInputs {
829-
if workflowFile == "" {
830-
return fmt.Errorf("--safe-inputs flag requires a workflow file to be specified")
831-
}
832-
if spawnInspector {
833-
return fmt.Errorf("--safe-inputs already includes inspector functionality; do not use --inspector flag with --safe-inputs")
834-
}
835-
if serverFilter != "" || toolFilter != "" {
836-
return fmt.Errorf("--safe-inputs cannot be used with --server or --tool flags")
837-
}
838-
return spawnSafeInputsInspector(workflowFile, verbose)
839-
}
840-
841931
// Handle spawn inspector flag
842932
if spawnInspector {
843933
return spawnMCPInspector(workflowFile, serverFilter, verbose)
@@ -851,7 +941,6 @@ The command will:
851941
cmd.Flags().StringVar(&toolFilter, "tool", "", "Show detailed information about a specific tool (requires --server)")
852942
cmd.Flags().BoolVar(&spawnInspector, "inspector", false, "Launch the official @modelcontextprotocol/inspector tool")
853943
cmd.Flags().BoolVar(&checkSecrets, "check-secrets", false, "Check GitHub Actions repository secrets for missing secrets")
854-
cmd.Flags().BoolVar(&safeInputs, "safe-inputs", false, "Launch safe-inputs MCP server and inspect it")
855944

856945
// Register completions for mcp inspect command
857946
cmd.ValidArgsFunction = CompleteWorkflowNames

0 commit comments

Comments
 (0)