Skip to content

fix(mcp): distinguish missing vs empty in validate_required (#371)#401

Merged
norrietaylor merged 4 commits intonorrietaylor:mainfrom
Oddly:fix/371-validate-required-empty-vs-missing
Apr 25, 2026
Merged

fix(mcp): distinguish missing vs empty in validate_required (#371)#401
norrietaylor merged 4 commits intonorrietaylor:mainfrom
Oddly:fix/371-validate-required-empty-vs-missing

Conversation

@Oddly
Copy link
Copy Markdown
Collaborator

@Oddly Oddly commented Apr 23, 2026

Closes #371. Also adds regression tests for #372.

The motivating case is distillery_search(query=""): the server returned "Missing required fields: query" even though the field was present in the payload, and an agent that parses the message to correct its call would retry with the same empty query and loop. The fix splits validate_required into two categories — missing (absent from the dict or explicitly None) and empty (a present string that is empty or whitespace-only) — and produces a distinct "Field 'x' must be a non-empty string" message for the empty case. When both categories fail in one call, missing is reported first because it is the more fundamental error.

Whitespace-only strings like " " previously slipped through validation and produced confusing downstream behaviour (empty search results, NOT_FOUND on phantom ids). They are now rejected at the validator with the same "non-empty string" message.

I also migrated _handle_aggregate off its ad-hoc if not group_by check so it benefits from the same treatment, and added unit coverage for validate_required, validate_type, validate_enum, and validate_positive_int in a new tests/test_mcp_common.py — the public contract of these helpers was previously only tested via the tool handlers that call them.

While looking at #372 I confirmed the reported repros already pass on main: distillery_classify validates entry_type before the DB lookup (fixed in commit 7a199e8), and distillery_resolve_review validates action before the lookup. I added regression tests to both handlers so the ordering cannot silently regress. #372 itself can be closed once this lands.

Non-string falsy values (0, False, [], {}) remain valid — that never regressed, but it is now explicitly asserted.

Summary by CodeRabbit

  • Bug Fixes

    • Validation messages now distinguish missing required fields from empty/whitespace-only strings and prioritize reporting missing fields first.
    • Parameter validation runs before resource lookups so invalid parameters return errors earlier.
  • Tests

    • Added integration and unit tests covering missing vs empty inputs, enum/type/positive-int validation, and handler validation order for consistent user-facing error messages.

…ylor#371)

distillery_search(query="") previously returned "Missing required fields:
query" even though the field was present — an agent parsing the message
would retry with the same empty payload and loop. The helper now separates
absence (field not in arguments or explicitly None) from emptiness (a
present string that is empty or whitespace-only), and returns a distinct
"Field 'x' must be a non-empty string" message for the latter. Missing
takes precedence when both categories fail in one call.

Whitespace-only strings ("   ") are now rejected at the validator rather
than silently succeeding and producing confusing downstream behaviour
(empty search results, NOT_FOUND lookups, etc.).

Also migrated _handle_aggregate off its ad-hoc "if not group_by" check so
empty-string group_by gets the same treatment, and added unit coverage
for validate_required, validate_type, validate_enum, and
validate_positive_int in a new tests/test_mcp_common.py.

While auditing norrietaylor#372 I confirmed the reported repros already pass on main
(entry_type and action are validated before the DB lookup); added
regression tests to lock that in so the ordering does not silently
regress later.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 23, 2026

📝 Walkthrough

Walkthrough

validate_required now separates failures into Missing (absent or None) and Empty (present but empty/whitespace string) and returns distinct messages; the aggregate handler uses this validator for group_by. Tests added to assert missing-vs-empty semantics and validation-before-DB lookup ordering.

Changes

Cohort / File(s) Summary
Validation Logic Enhancement
src/distillery/mcp/tools/_common.py
Refactored validate_required to collect missing (absent or None) and empty (present but string is empty/whitespace), return distinct error messages, and treat non-string falsy values as valid. Docstring and return behavior updated.
Handler Integration
src/distillery/mcp/tools/search.py
distillery_aggregate now calls shared validate_required for group_by validation instead of relying on falsy checks/default ""; uses validated arguments["group_by"] afterward.
Unit tests for validators
tests/test_mcp_common.py
New unit tests covering validate_required (missing vs empty precedence, whitespace-only, None, non-string falsy), validate_type, validate_enum (invalid value message and label override), and validate_positive_int edge cases.
Integration & ordering tests
tests/test_mcp_classify.py, tests/test_mcp_coverage_gaps.py, tests/test_mcp_server.py
Added integration tests asserting handlers return INVALID_PARAMS with the correct message for missing vs empty fields and that parameter/schema validation runs before DB lookups.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐇 I nibbled at bugs and found a clue,
Missing and empty now tell what’s true.
Validators sing, and tests all cheer,
No more wrong hints to interfere.
Hop, hop — clarity is here! 🎉

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 36.36% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix(mcp): distinguish missing vs empty in validate_required (#371)' directly summarizes the main change: splitting validate_required to differentiate missing fields from empty strings, addressing issue #371.
Linked Issues check ✅ Passed The PR fully addresses all coding requirements from #371: validate_required now distinguishes missing (absent/None) from empty (present string that is empty/whitespace-only) fields [#371], returns appropriate error messages for each category [#371], and treats non-string falsy values as valid [#371]. The implementation is applied in _common.py and _handle_aggregate uses it [#371].
Out of Scope Changes check ✅ Passed All code changes directly support the #371 objectives: updates to validate_required logic, migration of _handle_aggregate to use the shared validator, comprehensive unit tests in test_mcp_common.py validating all helper functions, and regression tests verifying validation order before DB lookups. No extraneous changes detected.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
src/distillery/mcp/tools/_common.py (1)

128-142: LGTM — clean missing vs empty split.

Logic correctly partitions fields: arguments.get(f) collapses absent and explicit None into missing (matching the docstring), and the isinstance(value, str) and not value.strip() guard means non-string falsy values (0, False, [], {}) correctly skip both branches. Missing-first precedence is sensible for agent recovery.

Minor nit (optional): the singular message uses repr()-quoted field name (Field 'query' must be a non-empty string) while the plural form joins names unquoted (Fields must be non-empty strings: a, b). Consider quoting in the plural form too for consistency, e.g. ', '.join(repr(f) for f in empty). Not blocking.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/distillery/mcp/tools/_common.py` around lines 128 - 142, The plural error
message is inconsistent with the singular one because the singular uses repr()
quoting while the plural joins raw names; update the branch that builds the
plural message (the code that returns f"Fields must be non-empty strings: {',
'.join(empty)}") to join repr(f) for each f in empty so field names are quoted
consistently with the singular message — locate the logic around the local
variables missing, empty, fields and the final return paths and replace the join
with a repr-based join.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/distillery/mcp/tools/_common.py`:
- Around line 128-142: The plural error message is inconsistent with the
singular one because the singular uses repr() quoting while the plural joins raw
names; update the branch that builds the plural message (the code that returns
f"Fields must be non-empty strings: {', '.join(empty)}") to join repr(f) for
each f in empty so field names are quoted consistently with the singular message
— locate the logic around the local variables missing, empty, fields and the
final return paths and replace the join with a repr-based join.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: dad7a02b-dafa-46eb-bcfe-e358417c20d7

📥 Commits

Reviewing files that changed from the base of the PR and between 4d0fb64 and f53c0dc.

📒 Files selected for processing (6)
  • src/distillery/mcp/tools/_common.py
  • src/distillery/mcp/tools/search.py
  • tests/test_mcp_classify.py
  • tests/test_mcp_common.py
  • tests/test_mcp_coverage_gaps.py
  • tests/test_mcp_server.py

CodeRabbit caught that the plural branch of validate_required joined raw
field names ("Fields must be non-empty strings: a, b") while the singular
branch used repr quoting ("Field 'a' must be a non-empty string"). Align
the two by repr-quoting each name in the plural join.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
tests/test_mcp_common.py (1)

24-159: Solid coverage of the new contract.

Tests pin down the missing/empty split, ordering precedence, whitespace handling, and the non-string falsy cases called out in issue #371. The validate_positive_int bool-rejection test is a nice guard against Python's bool <: int quirk.

One minor gap worth considering: there's no assertion that validate_positive_int returns the INVALID_PARAMS code (only that the result is a tuple). Since the tuple is consumed by error_response(code, message), a quick result[0] == "INVALID_PARAMS" check would lock in the public contract and catch accidental code-string drift.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/test_mcp_common.py` around lines 24 - 159, Add assertions in the
TestValidatePositiveInt tests that currently assert a tuple result to also
verify the error code is the expected public contract string; specifically, in
tests test_missing_without_default_returns_error, test_zero_rejected,
test_negative_rejected, test_non_int_rejected, and test_bool_rejected where
validate_positive_int(...) returns a tuple, assert result[0] == "INVALID_PARAMS"
in addition to the existing isinstance(result, tuple) checks so the tests lock
in the error code returned by validate_positive_int.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@tests/test_mcp_common.py`:
- Around line 24-159: Add assertions in the TestValidatePositiveInt tests that
currently assert a tuple result to also verify the error code is the expected
public contract string; specifically, in tests
test_missing_without_default_returns_error, test_zero_rejected,
test_negative_rejected, test_non_int_rejected, and test_bool_rejected where
validate_positive_int(...) returns a tuple, assert result[0] == "INVALID_PARAMS"
in addition to the existing isinstance(result, tuple) checks so the tests lock
in the error code returned by validate_positive_int.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 3ea2ded8-8ae0-4998-8862-296fa85cccd7

📥 Commits

Reviewing files that changed from the base of the PR and between f53c0dc and 0512581.

📒 Files selected for processing (2)
  • src/distillery/mcp/tools/_common.py
  • tests/test_mcp_common.py

@norrietaylor norrietaylor merged commit 60a61a1 into norrietaylor:main Apr 25, 2026
6 of 7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

distillery_search empty-string query returns 'Missing required fields: query' — empty ≠ missing

2 participants