diff --git a/src/mcp/mcp.c b/src/mcp/mcp.c index 8f75a2602..329da7365 100644 --- a/src/mcp/mcp.c +++ b/src/mcp/mcp.c @@ -3536,6 +3536,9 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { } if (!validate_search_args(root_path, file_pattern)) { + if (has_path_filter) { + cbm_regfree(&path_regex); + } free(root_path); free(pattern); free(project); @@ -3543,6 +3546,27 @@ static char *handle_search_code(cbm_mcp_server_t *srv, const char *args) { return cbm_mcp_text_result("path or file_pattern contains invalid characters", true); } + /* issue #283: when regex=true, a syntactically invalid pattern (e.g. an + * unclosed group) makes the underlying grep fail, which the handler would + * otherwise report as an empty result set — indistinguishable from a + * legitimate no-match. Validate the user's regex up front and return an + * explicit error so callers can tell "broken pattern" from "no matches". */ + if (use_regex) { + cbm_regex_t probe; + if (cbm_regcomp(&probe, pattern, CBM_REG_EXTENDED | CBM_REG_NOSUB) != CBM_REG_OK) { + if (has_path_filter) { + cbm_regfree(&path_regex); + } + free(root_path); + free(pattern); + free(project); + free(file_pattern); + return cbm_mcp_text_result( + "invalid regex pattern (regex=true): check for unbalanced (), [], or {}", true); + } + cbm_regfree(&probe); + } + /* ── Phase 0.5: Multi-word → regex conversion ───────────── */ /* If pattern contains whitespace and is not already a regex, convert to a * regex that matches all words in order: "foo bar baz" → "foo.*bar.*baz". diff --git a/tests/test_mcp.c b/tests/test_mcp.c index 047962421..5a8614aab 100644 --- a/tests/test_mcp.c +++ b/tests/test_mcp.c @@ -739,6 +739,39 @@ TEST(search_code_multi_word) { PASS(); } +/* issue #283: search_code with regex=true and a syntactically invalid pattern + * must return an explicit error, not an empty result indistinguishable from a + * legitimate no-match. */ +TEST(search_code_invalid_regex_errors_issue283) { + char tmp[512]; + cbm_mcp_server_t *srv = setup_snippet_server(tmp, sizeof(tmp)); + ASSERT_NOT_NULL(srv); + + /* Unclosed group under regex=true → must be flagged as an error. */ + char *resp = + cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":91,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"func(\",\"regex\":true," + "\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_NOT_NULL(strstr(resp, "\"isError\":true")); + ASSERT_NOT_NULL(strstr(resp, "invalid regex")); + free(resp); + + /* Same pattern as a literal (regex=false) must NOT error. */ + resp = cbm_mcp_server_handle(srv, "{\"jsonrpc\":\"2.0\",\"id\":92,\"method\":\"tools/call\"," + "\"params\":{\"name\":\"search_code\"," + "\"arguments\":{\"pattern\":\"func(\",\"regex\":false," + "\"project\":\"test-project\"}}}"); + ASSERT_NOT_NULL(resp); + ASSERT_TRUE(strstr(resp, "invalid regex") == NULL); + free(resp); + + cleanup_snippet_dir(tmp); + cbm_mcp_server_free(srv); + PASS(); +} + TEST(tool_detect_changes_no_project) { cbm_mcp_server_t *srv = cbm_mcp_server_new(NULL); @@ -1937,6 +1970,7 @@ SUITE(mcp) { RUN_TEST(tool_search_code_missing_pattern); RUN_TEST(tool_search_code_no_project); RUN_TEST(search_code_multi_word); + RUN_TEST(search_code_invalid_regex_errors_issue283); RUN_TEST(tool_detect_changes_no_project); RUN_TEST(tool_manage_adr_no_project); RUN_TEST(tool_manage_adr_get_with_existing_adr);