Skip to content

fix: restore schema-aware probe detection for zero-arg safeoutputs tools - #49313

Merged
pelikhan merged 2 commits into
mainfrom
copilot/zero-argument-safe-output-fix
Jul 31, 2026
Merged

fix: restore schema-aware probe detection for zero-arg safeoutputs tools#49313
pelikhan merged 2 commits into
mainfrom
copilot/zero-argument-safe-output-fix

Conversation

Copilot AI commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Zero-argument custom safe-output tools (no inputs declared) were silently blocked from reaching MCP tools/call. The bridge treated every empty-args call to any safeoutputs tool as a schema-discovery probe, showing help instead of calling the tool — so no safe-output item was recorded and downstream custom job if: conditions evaluated false.

Root cause

shouldShowToolHelpForEmptyArgs was schema-blind — it returned true for all empty-arg safeoutputs calls regardless of whether the tool had required fields:

// Before
function shouldShowToolHelpForEmptyArgs(serverName, toolArgs) {
  return serverName === SAFEOUTPUTS_SERVER_NAME && Object.keys(toolArgs).length === 0;
}

The compiler correctly generates { type: "object", properties: {}, additionalProperties: false } (no required key) for zero-input jobs. The bridge just wasn't respecting it.

Changes

  • mcp_cli_bridge.cjsshouldShowToolHelpForEmptyArgs now accepts the resolved matchedTool and inspects inputSchema.required. Help is shown only when the array is non-empty; zero-input and optional-only tools pass through to tools/call with {}. The -32602 non-retry hint for genuinely invalid calls is preserved.

  • mcp_cli_bridge.test.cjs — replaces the old schema-blind test with a schema-aware suite covering empty schema → false, optional-only → false, missing tool → false, non-empty required → true; adds bridge-level end-to-end tests via a local in-process HTTP server asserting that bare and piped-{} invocations of a zero-input tool reach tools/call with arguments: {}, and that a required-input tool still shows help.

  • safe_outputs_tools_meta_integration_test.go — new TestToolsMetaJSONCompiledWorkflowZeroArgCustomJob compiles a workflow with an inputs-omitted custom job and asserts the dynamic_tools entry has properties: {}, no required key, and additionalProperties: false, protecting the compiler/bridge contract.

  • .changeset/fix-zero-arg-safeoutputs-tools.md — patch changeset.

Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix zero-argument tools to call MCP instead of showing help fix: restore schema-aware probe detection for zero-arg safeoutputs tools Jul 31, 2026
Copilot AI requested a review from pelikhan July 31, 2026 10:49
@pelikhan
pelikhan marked this pull request as ready for review July 31, 2026 11:35
Copilot AI review requested due to automatic review settings July 31, 2026 11:35
@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

🧠 Matt Pocock Skills Reviewer has completed the skills-based review. ✅

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Design Decision Gate 🏗️ completed the design decision gate check.

No ADR enforcement needed: PR does not have the implementation label and has 63 new lines of code in business logic directories (≤100 threshold).

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Test Quality Sentinel completed test quality analysis.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

PR Code Quality Reviewer completed the code quality review.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The piped sentinel test overwrites its sentinel arguments before calling the bridge, leaving that invocation path untested.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Restores schema-aware handling of empty arguments for safe-output tools.

Changes:

  • Allows zero-input and optional-only tools to reach MCP tools/call.
  • Adds bridge and compiler contract tests.
  • Adds a patch changeset.
File summaries
File Description
actions/setup/js/mcp_cli_bridge.cjs Checks required schema fields before showing help.
actions/setup/js/mcp_cli_bridge.test.cjs Adds guard and MCP routing tests.
pkg/workflow/safe_outputs_tools_meta_integration_test.go Verifies zero-input compiled schemas.
.changeset/fix-zero-arg-safeoutputs-tools.md Documents the patch fix.
Review details
  • Files reviewed: 4/4 changed files
  • Comments generated: 1
  • Review effort level: Balanced

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

fsReadSyncSpy.mockImplementationOnce(() => 0); // EOF on second read

try {
setupMainCall(zeroInputTools, ["dispatch_code_factory"]);
@github-actions

Copy link
Copy Markdown
Contributor

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

🧪 Test Quality Sentinel Report

⚠️ Test Quality Score: 78/100 — Acceptable

Analyzed 5 test(s): 5 design, 0 implementation, 0 violation(s).

📊 Metrics (5 tests)
Metric Value
Analyzed 5 (Go: 1, JS: 4)
✅ Design 5 (100%)
⚠️ Implementation 0 (0%)
Edge/error coverage 3 (60%)
Duplicate clusters 0
Inflation YES — mcp_cli_bridge.test.cjs +199 lines vs mcp_cli_bridge.cjs +16 lines (12.4:1)
🚨 Violations 0
Test File Classification Issues
allows zero-argument tools to proceed mcp_cli_bridge.test.cjs:207 design_test / behavioral_contract / high_value None
reaches tools/call with {} (bare invocation) mcp_cli_bridge.test.cjs:~1275 design_test / behavioral_contract / high_value None
reaches tools/call with {} (piped stdin) mcp_cli_bridge.test.cjs:~1290 design_test / behavioral_contract / high_value None
shows help; does NOT reach tools/call mcp_cli_bridge.test.cjs:~1320 design_test / behavioral_contract / high_value None
TestToolsMetaJSONCompiledWorkflowZeroArgCustomJob safe_outputs_tools_meta_integration_test.go:121 design_test / behavioral_contract / high_value None
📝 Inflation Note

The 12.4:1 JS test-to-prod ratio is justified: the new main() integration tests spin up a full in-process MCP HTTP server (~90 LOC setup) to exercise zero-arg routing end-to-end. The production fix is minimal (16 lines). The infrastructure cost is high but the coverage value is proportionate — tests verify the full protocol path including initialize, tools/call, and the negative case where tools/call must be absent.

Verdict

passed. 0% implementation tests (threshold: 30%). All 5 tests verify behavioral contracts for the zero-arg safe-outputs fix across both the JS bridge and Go compiler layers. Inflation is noted but structurally justified by integration depth.

References:

🧪 Test quality analysis by Test Quality Sentinel · sonnet46 · 35.4 AIC · ⊞ 8.4K ·
Comment /review to run again

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Test Quality Sentinel: 78/100. 0% implementation tests (threshold: 30%).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Review: schema-aware probe detection fix

The core fix in shouldShowToolHelpForEmptyArgs is correct and well-scoped. The function signature update, null-safe required array check, and the Go integration test that pins the compiler/bridge contract are all solid.

One non-blocking issue flagged inline — the piped {} via . sentinel test has a duplicate setupMainCall inside its try block that replaces the outer one (losing the . sentinel), making the piped-stdin routing untested as written. The readStdinSync spy also likely won't intercept the CJS closure binding. Suggest simplifying or removing that test in favour of the already-correct bare-invocation test.

No blocking issues found.

🧵 Reviewed using Impeccable skills by Impeccable Skills Reviewer · sonnet46 · 25.4 AIC · ⊞ 5.3K

await main();

const toolsCallBody = recordedBodies.find(b => b.method === "tools/call");
expect(toolsCallBody).toBeDefined();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The setupMainCall inside the try block overwrites process.argv without the . sentinel, making the outer setupMainCall(zeroInputTools, ["dispatch_code_factory", "."]) dead code — hasStdinJsonPayload will not see . and the piped-stdin path is not exercised as intended.

Additionally, vi.spyOn(require("./mcp_cli_bridge.cjs"), "readStdinSync") is unlikely to intercept the binding captured inside main() (CJS closure), which is why the test uses mockRestore?.(). The test only exercises the fs.readSync mock, not true stdin routing.

Suggested fix: remove the duplicate setupMainCall inside try (keep only the one with ["dispatch_code_factory", "."] before the spy setup), or simplify the test to trust the bare-invocation case already covers zero-arg routing.

@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Skills-Based Review 🧠

Applied /diagnosing-bugs and /tdd — requesting changes on two test correctness issues.

📋 Key Themes & Highlights

Key Themes

  • Vacuous test: the "piped stdin" end-to-end test has a duplicate setupMainCall that overwrites the .-sentinel argv before main() runs, so the piped-stdin code path is never actually exercised.
  • Dead spy: the vi.spyOn(require('./mcp_cli_bridge.cjs'), 'readStdinSync') call cannot intercept the internal CJS binding that main() holds — the spy does nothing, and the mockRestore?.() guard suggests the author sensed something was off.

Positive Highlights

  • ✅ Root cause is correctly identified and the fix is minimal and precise.
  • ✅ Schema-aware unit tests for shouldShowToolHelpForEmptyArgs are thorough — all boundary cases (empty schema, optional-only, empty required array, null/undefined tool) are covered.
  • ✅ Go integration test protects the compiler/bridge contract cleanly.
  • ✅ Conservative fallback (no-required when schema is missing) is the right default.

🧠 Reviewed using Matt Pocock's skills by Matt Pocock Skills Reviewer · sonnet46 · 46.4 AIC · ⊞ 7K
Comment /matt to run again

fsReadSyncSpy.mockImplementationOnce(() => 0); // EOF on second read

try {
setupMainCall(zeroInputTools, ["dispatch_code_factory"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] The .\ sentinel set on line 1295 is overwritten by a second setupMainCall call here (without the . argument), so hasStdinJsonPayload will receive ["dispatch_code_factory"] rather than ["."] — the piped-stdin branch is never actually exercised.

💡 Suggested fix

Remove the duplicate setupMainCall call inside the try block. The process.argv was already configured at line 1295 with the . sentinel; the second call silently resets it:

// Remove this line — it replaces argv that was set with ["."]
// setupMainCall(zeroInputTools, ["dispatch_code_factory"]);
await main();

Without the fix the piped-stdin assertion passes vacuously: the test reaches tools/call via the zero-arg (non-stdin) path, not via readStdinSync.

@copilot please address this.

setupMainCall(zeroInputTools, ["dispatch_code_factory", "."]);

// Simulate piped `{}` via the . sentinel with stdinContent = "{}"
const readStdinSyncSpy = vi.spyOn(/** @type {any} */ require("./mcp_cli_bridge.cjs"), "readStdinSync");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[/tdd] vi.spyOn on a CommonJS require() call here won't intercept the readStdinSync that main() already closed over at module-load time — so this spy is effectively dead code and the test is not actually verifying what the comment says it does.

💡 Context

In CJS modules, require() returns a cached module object. If readStdinSync is a local binding (not re-exported as an enumerable property), spying on the cached export does not affect the internal call inside main(). This means the readStdinSync used by main() will call through to the real implementation regardless of the spy.

The fsReadSyncSpy on fs.readSync does intercept the right level, which is why the test likely still passes — but the readStdinSyncSpy lines are misleading. Consider removing them or replacing this test with one that directly exercises readStdinSync independently, keeping the main()-level test simpler.

@copilot please address this.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Warning

threat detection engine error
The threat detection engine encountered an error and could not complete analysis. This is a tooling failure, not a security finding.

Details

The threat detection engine failed to produce results.

Review the workflow run logs for details.

Verdict: Request changes

Core fix logic (checking inputSchema.required) is directionally correct, but the new end-to-end test for the piped-stdin path is broken (never exercises what it claims to), and the fallback for an unresolved tool schema silently permits invalid empty-arg calls through to tools/call.

💡 Themes
  1. Test validity: the "piped {} via . sentinel" test calls setupMainCall() twice, with the second call overwriting the first's .-sentinel argv before main() runs — so the stdin-mocking machinery (fs.readSync mock, isTTY override, module spy) is entirely inert. The test passes for the same trivial reason as the bare-invocation test above it, giving false confidence in stdin/sentinel handling.
  2. Source logic gap: shouldShowToolHelpForEmptyArgs treats "tool not found in cache" (null matchedTool) the same as "tool found with a genuinely empty schema", both defaulting to required = [] and thus allowing the empty-arg call through. This means unknown/stale/mistyped tool names now bypass the fast local help path and silently hit the MCP server instead — reintroducing the original wasteful round-trip problem for a different trigger.

Good parts: the zero-arg-tool Go integration test (TestToolsMetaJSONCompiledWorkflowZeroArgCustomJob) is solid and correctly locks the compiler/bridge contract.

🔎 Code quality review by PR Code Quality Reviewer · auto · 65.6 AIC · ⊞ 7.8K
Comment /review to run again

fsReadSyncSpy.mockImplementationOnce(() => 0); // EOF on second read

try {
setupMainCall(zeroInputTools, ["dispatch_code_factory"]);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test's second setupMainCall() call silently overwrites the first (with the . sentinel), so the test never actually exercises the piped-stdin path it claims to verify.

💡 Details

Line 1295 sets process.argv to include the . sentinel and mocks fs.readSync/isTTY to simulate piped stdin. But line 1315 calls setupMainCall(zeroInputTools, ["dispatch_code_factory"]) again — with no . — right before await main(), which overwrites process.argv and creates a brand-new toolsFile (leaking the first one, since only the last toolsFile value is cleaned up in afterEach). The test therefore passes for the exact same reason as the "bare invocation" test above it (empty args routed to a zero-required-fields schema), not because of stdin/sentinel handling. The elaborate fs.readSync mock and readStdinSyncSpy are dead code with respect to what the test title promises.

Fix: remove the duplicate setupMainCall call on line 1315 so the mocked stdin path from line 1295 is what actually runs through main().

setupMainCall(zeroInputTools, ["dispatch_code_factory", "."]);

// Simulate piped `{}` via the . sentinel with stdinContent = "{}"
const readStdinSyncSpy = vi.spyOn(/** @type {any} */ require("./mcp_cli_bridge.cjs"), "readStdinSync");

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The vi.spyOn on readStdinSync from a fresh require() never intercepts the call main() actually makes, so it's dead test scaffolding that misrepresents what's verified.

💡 Details

readStdinSync() is invoked directly inside main() as an internal CommonJS reference, not via module.exports.readStdinSync. Spying on the exported reference from a separate require("./mcp_cli_bridge.cjs") therefore cannot patch the call site main() uses — the spy is set up, never asserted on, and mockRestore?.() even hints the author suspected it might not be a real spy. Combined with the duplicate setupMainCall issue flagged above, none of this stdin-simulation machinery in the test actually does anything; it should either be removed or the module needs to expose stdin reading through an injectable seam (e.g. accept a reader function) so it can genuinely be mocked.

// Show help only when the schema explicitly declares required fields.
// Zero-argument and optional-only tools have no required array (or an empty one)
// and must be allowed to call through to MCP so their output item is recorded.
const required = matchedTool && matchedTool.inputSchema && Array.isArray(matchedTool.inputSchema.required) ? matchedTool.inputSchema.required : [];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Treating an unresolved matchedTool (unknown tool name) the same as a zero-input schema means a genuinely-required-arg tool called with empty args can silently reach tools/call with {} whenever the local tool cache is stale or the name is mistyped.

💡 Details

shouldShowToolHelpForEmptyArgs defaults required to [] whenever matchedTool is null/undefined — which happens both for legitimately schema-less tools and for tools not found in the cached tools array at all (e.g. stale toolsFile, race with a newly-registered custom job, or a simple typo in toolName). In the "not found" case the caller previously got a fast, clear local help message; now it silently proceeds to a network round-trip that will likely fail server-side with a less actionable -32602, regressing exactly the wasteful-retry problem this function's docstring says it exists to prevent — just for a different trigger. Consider distinguishing "tool not found in cache" (show help / unknown command error, as showToolHelp already does) from "tool found with an empty/optional schema" (proceed to call).

@pelikhan

Copy link
Copy Markdown
Collaborator

@copilot run pr-finisher skill

@pelikhan
pelikhan merged commit e694c70 into main Jul 31, 2026
92 of 113 checks passed
@pelikhan
pelikhan deleted the copilot/zero-argument-safe-output-fix branch July 31, 2026 12:20
Copilot stopped work on behalf of pelikhan due to an error July 31, 2026 12:20
@github-actions

Copy link
Copy Markdown
Contributor

🎉 This pull request is included in a new release.

Release: v0.84.1

@github-actions github-actions Bot mentioned this pull request Jul 31, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Zero-argument custom safe-output tools show help instead of calling MCP

3 participants