Skip to content

Commit d407af3

Browse files
authored
fix(mcp): audit/audit-diff return graceful JSON errors instead of IsError=true (#28291)
1 parent 3645a84 commit d407af3

4 files changed

Lines changed: 47 additions & 21 deletions

File tree

pkg/cli/mcp_server_error_codes_test.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -171,6 +171,12 @@ func TestMCPServer_ErrorCodes_InternalError(t *testing.T) {
171171
return
172172
}
173173

174+
// The tool must not set IsError=true – that would cause the bridge to exit with code 1.
175+
if result.IsError {
176+
t.Errorf("Expected IsError=false from audit tool (graceful JSON error), got IsError=true; content: %v", result.Content)
177+
return
178+
}
179+
174180
// The tool must return text content that is valid JSON containing an "error" field.
175181
if len(result.Content) == 0 {
176182
t.Fatal("Expected non-empty content from audit tool")
@@ -191,6 +197,9 @@ func TestMCPServer_ErrorCodes_InternalError(t *testing.T) {
191197
if _, hasRunID := envelope["run_id_or_url"]; !hasRunID {
192198
t.Errorf("Expected 'run_id_or_url' field in audit JSON response, got: %s", textContent.Text)
193199
}
194-
t.Logf("✓ audit returned JSON error envelope: %s", textContent.Text)
200+
if _, hasSuggestions := envelope["suggestions"]; !hasSuggestions {
201+
t.Errorf("Expected 'suggestions' field in audit JSON response, got: %s", textContent.Text)
202+
}
203+
t.Logf("✓ audit returned graceful JSON error envelope (IsError=false): %s", textContent.Text)
195204
})
196205
}

pkg/cli/mcp_server_json_integration_test.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ func TestMCPServer_AuditToolReturnsValidJSON(t *testing.T) {
236236
defer session.Close()
237237

238238
// Call audit tool with an invalid run ID
239-
// The tool should return an MCP error for invalid run IDs
239+
// The tool should return a graceful JSON error (IsError=false), not a protocol-level MCP error.
240240
params := &mcp.CallToolParams{
241241
Name: "audit",
242242
Arguments: map[string]any{
@@ -245,8 +245,8 @@ func TestMCPServer_AuditToolReturnsValidJSON(t *testing.T) {
245245
}
246246
result, err := session.CallTool(ctx, params)
247247
if err != nil {
248-
// Expected behavior: audit command fails with invalid run ID
249-
t.Logf("Audit tool correctly returned error for invalid run ID: %v", err)
248+
// A permission or network error is acceptable in environments without GitHub credentials.
249+
t.Logf("Audit tool returned protocol error (acceptable in CI without credentials): %v", err)
250250
return
251251
}
252252

@@ -265,14 +265,14 @@ func TestMCPServer_AuditToolReturnsValidJSON(t *testing.T) {
265265
t.Fatal("Expected non-empty text content from audit tool")
266266
}
267267

268-
// The audit tool may return an error message for invalid run IDs
269-
// We check if the output contains "Error:" which indicates an error message
270-
if len(textContent.Text) >= 6 && textContent.Text[0:6] == "Error:" {
271-
t.Logf("Audit tool returned error message as expected for invalid run ID")
268+
// The audit tool must return valid JSON (graceful error envelope or success)
269+
// and must NOT set IsError=true.
270+
if result.IsError {
271+
t.Errorf("Audit tool set IsError=true; expected graceful JSON error (IsError=false). Content: %s", textContent.Text)
272272
return
273273
}
274274

275-
// If not an error, verify JSON is valid
275+
// Verify JSON is valid
276276
if !isValidJSON(textContent.Text) {
277277
// Try extracting JSON from output
278278
jsonOutput := extractJSONFromOutput(textContent.Text)

pkg/cli/mcp_tools_privileged.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -351,16 +351,22 @@ Returns JSON with the following structure:
351351

352352
// Return a JSON error envelope instead of an MCP protocol error so
353353
// callers always receive consistent JSON and the run ID is always present.
354+
// IsError must be false so that callers (e.g. mcp_cli_bridge) treat this as
355+
// a graceful not-found / failure response rather than a fatal protocol error.
354356
errorEnvelope := map[string]any{
355357
"error": "failed to audit workflow run: " + mainMsg,
356358
"run_id_or_url": args.RunIDOrURL,
359+
"suggestions": []string{
360+
"Verify the run ID is correct",
361+
"Use the 'logs' tool to list recent run IDs",
362+
},
357363
}
358364
jsonBytes, jsonErr := json.Marshal(errorEnvelope)
359365
if jsonErr != nil {
360366
return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to audit workflow run: "+mainMsg, nil)
361367
}
362368
return &mcp.CallToolResult{
363-
IsError: true,
369+
IsError: false,
364370
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonBytes)}},
365371
}, nil, nil
366372
}
@@ -457,13 +463,17 @@ Returns JSON describing the differences between the base run and each comparison
457463
"error": "failed to diff workflow runs: " + mainMsg,
458464
"base_run_id": args.BaseRunID,
459465
"compare_runs": args.CompareRunIDs,
466+
"suggestions": []string{
467+
"Verify the run IDs are correct",
468+
"Use the 'logs' tool to list recent run IDs",
469+
},
460470
}
461471
jsonBytes, jsonErr := json.Marshal(errorEnvelope)
462472
if jsonErr != nil {
463473
return nil, nil, newMCPError(jsonrpc.CodeInternalError, "failed to diff workflow runs: "+mainMsg, nil)
464474
}
465475
return &mcp.CallToolResult{
466-
IsError: true,
476+
IsError: false,
467477
Content: []mcp.Content{&mcp.TextContent{Text: string(jsonBytes)}},
468478
}, nil, nil
469479
}

pkg/cli/mcp_tools_privileged_test.go

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -257,11 +257,12 @@ func TestAuditToolPassesGithubRepositoryAsRepoFlag(t *testing.T) {
257257
}
258258
}
259259

260-
// TestAuditToolErrorEnvelopeSetsIsErrorTrue verifies that audit command failures
261-
// returned as JSON envelopes are marked with IsError=true in the MCP response.
262-
func TestAuditToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
260+
// TestAuditToolErrorEnvelopeSetsIsErrorFalse verifies that audit command failures
261+
// returned as JSON envelopes use IsError=false so callers receive graceful JSON
262+
// rather than a fatal MCP protocol error.
263+
func TestAuditToolErrorEnvelopeSetsIsErrorFalse(t *testing.T) {
263264
mockExecCmd := func(ctx context.Context, args ...string) *exec.Cmd {
264-
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestAuditToolErrorEnvelopeSetsIsErrorTrueHelperProcess")
265+
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestAuditToolErrorEnvelopeHelperProcess")
265266
cmd.Env = append(os.Environ(), "GH_AW_AUDIT_HELPER_PROCESS=1")
266267
return cmd
267268
}
@@ -278,7 +279,7 @@ func TestAuditToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
278279
})
279280
require.NoError(t, err, "audit tool should return result envelope without protocol error")
280281
require.NotNil(t, result, "result should not be nil")
281-
assert.True(t, result.IsError, "audit error envelope should set IsError=true")
282+
assert.False(t, result.IsError, "audit error envelope should set IsError=false (graceful JSON error)")
282283
require.NotEmpty(t, result.Content, "result should contain text content")
283284

284285
textContent, ok := result.Content[0].(*mcp.TextContent)
@@ -290,9 +291,12 @@ func TestAuditToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
290291
errorMessage, ok := envelope["error"].(string)
291292
require.True(t, ok, "error envelope should include string error field")
292293
assert.Contains(t, errorMessage, "failed to audit workflow run", "error envelope should include contextual prefix")
294+
suggestions, hasSuggestions := envelope["suggestions"]
295+
assert.True(t, hasSuggestions, "error envelope should include suggestions")
296+
assert.NotEmpty(t, suggestions, "suggestions should not be empty")
293297
}
294298

295-
func TestAuditToolErrorEnvelopeSetsIsErrorTrueHelperProcess(t *testing.T) {
299+
func TestAuditToolErrorEnvelopeHelperProcess(t *testing.T) {
296300
if os.Getenv("GH_AW_AUDIT_HELPER_PROCESS") != "1" {
297301
return
298302
}
@@ -331,9 +335,9 @@ func TestAuditTool_AcceptsDeprecatedMaxTokensParameter(t *testing.T) {
331335
assert.NotContains(t, strings.Join(capturedArgs, " "), "max_tokens", "audit command args should ignore max_tokens")
332336
}
333337

334-
func TestAuditDiffToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
338+
func TestAuditDiffToolErrorEnvelopeSetsIsErrorFalse(t *testing.T) {
335339
mockExecCmd := func(ctx context.Context, args ...string) *exec.Cmd {
336-
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestAuditDiffToolErrorEnvelopeSetsIsErrorTrueHelperProcess")
340+
cmd := exec.CommandContext(ctx, os.Args[0], "-test.run=TestAuditDiffToolErrorEnvelopeHelperProcess")
337341
cmd.Env = append(os.Environ(), "GH_AW_AUDIT_DIFF_HELPER_PROCESS=1")
338342
return cmd
339343
}
@@ -353,7 +357,7 @@ func TestAuditDiffToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
353357
})
354358
require.NoError(t, err, "audit-diff tool should return result envelope without protocol error")
355359
require.NotNil(t, result, "result should not be nil")
356-
assert.True(t, result.IsError, "audit-diff error envelope should set IsError=true")
360+
assert.False(t, result.IsError, "audit-diff error envelope should set IsError=false (graceful JSON error)")
357361
require.NotEmpty(t, result.Content, "result should contain text content")
358362

359363
textContent, ok := result.Content[0].(*mcp.TextContent)
@@ -365,9 +369,12 @@ func TestAuditDiffToolErrorEnvelopeSetsIsErrorTrue(t *testing.T) {
365369
errorMessage, ok := envelope["error"].(string)
366370
require.True(t, ok, "error envelope should include string error field")
367371
assert.Contains(t, errorMessage, "failed to diff workflow runs", "error envelope should include contextual prefix")
372+
suggestions, hasSuggestions := envelope["suggestions"]
373+
assert.True(t, hasSuggestions, "error envelope should include suggestions")
374+
assert.NotEmpty(t, suggestions, "suggestions should not be empty")
368375
}
369376

370-
func TestAuditDiffToolErrorEnvelopeSetsIsErrorTrueHelperProcess(t *testing.T) {
377+
func TestAuditDiffToolErrorEnvelopeHelperProcess(t *testing.T) {
371378
if os.Getenv("GH_AW_AUDIT_DIFF_HELPER_PROCESS") != "1" {
372379
return
373380
}

0 commit comments

Comments
 (0)