You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Several MCP tool parameters that have a small, fixed set of valid values are typed as str instead of Literal[...]. This means weak models (Qwen Code, Mistral-Small, some Llama tunes) only learn the valid values from the natural-language Field(description=...) text, not from the JSON Schema enum. Switching to Literal[...] exposes a discriminated string enum that FastMCP rejects at parse time, instead of letting bad values reach mcp_v2.py's runtime validation.
Background
This was flagged as Notes #4 in the PR #54 review and again referenced in the carry-forward backlog of the PR #55 review.
The pattern that's already proven in the codebase: neighbors_v2 made direction and edge_typesrequired in PR-V2-1 and immediately got more reliable model behaviour. Adding Literal[...] types is the same idea applied at the type level — schema-grounded decoding gets a hard structural cue, not just prose.
Parameters affected
1. neighbors.direction
Today (mcp_v2.py / server.py):
direction: str=Field(..., description="Required: in (predecessors) or out (successors); no default")
Why now: weakest models occasionally emit direction="upstream", "downstream", "forward", "backward", "both". All currently fail with a KuzuGraph Cypher error or empty result rather than a parse-time rejection.
Why now: the closest valid-but-wrong values are "method", "class", "endpoint", "call" — all of which a model could plausibly emit. Today these silently produce empty results because _dispatch falls through to a default branch.
Why now: the 9 valid edge labels are now enumerated verbatim in _INSTRUCTIONS (PR #54), but a model can still emit "calls" (lowercase), "INHERITS", "USES". Today these reach the Cypher query and either return empty (case mismatch) or raise an error (unknown rel type). With Literal, FastMCP rejects at parse time with a useful message naming the valid values.
Implementation notes
All four changes are type-level only — no runtime behaviour change for callers that pass valid values.
mcp_v2.py@validate_call already runs Pydantic validation; Literal[...] integrates natively, no extra glue.
The Field(description=...) strings should be shortened when the type itself enumerates valid values — redundant prose can confuse weak models more than it helps. Keep the description focused on semantics ("predecessors vs successors"), let the type carry the taxonomy.
Verify the FastMCP-emitted JSON Schema actually surfaces a string enum (not a free-form string) — past schema flattening work (PR feat: tolerate JSON-encoded NodeFilter in MCP search/find/neighbors #55) showed FastMCP can collapse nested types unexpectedly. Worth a one-test schema-introspection assertion.
Definition of done
neighbors.direction typed as Literal["in","out"]
find.kind typed as Literal["symbol","route","client"]
search.table typed as Literal["java","sql","yaml","all"]
neighbors.edge_types items typed via EdgeType = Literal[...]
tests/test_mcp_v2.py adds 4 negative tests asserting ValidationError on a bad enum value for each parameter
One schema-introspection test that asserts each tool's JSON Schema has an enum array on the relevant property (locks the gain against future FastMCP/Pydantic behaviour changes)
Updated Field(description=...) strings — shortened where the type now carries the taxonomy
No README changes needed (the README already lists valid values prose-style; that stays as human-readable doc)
Don't change direction / kind / table to Enum classes — Literal is lighter, FastMCP-native, and produces the same JSON Schema enum.
Don't add server-side normalization (lowercase → uppercase for edge_types, "upstream" → "in", etc.). The point of Literal is to fail loudly with a useful message, not to silently accept synonyms. Synonym tolerance is a separate, more complicated design call.
Single Cursor task PR, additive type-tightening only, no schema delta, no ontology bump. Branch name suggestion: feat/mcp-v2-literal-types. ~30 lines changed across mcp_v2.py + server.py, plus tests.
Summary
Several MCP tool parameters that have a small, fixed set of valid values are typed as
strinstead ofLiteral[...]. This means weak models (Qwen Code, Mistral-Small, some Llama tunes) only learn the valid values from the natural-languageField(description=...)text, not from the JSON Schema enum. Switching toLiteral[...]exposes a discriminated string enum that FastMCP rejects at parse time, instead of letting bad values reachmcp_v2.py's runtime validation.Background
This was flagged as Notes #4 in the PR #54 review and again referenced in the carry-forward backlog of the PR #55 review.
The pattern that's already proven in the codebase:
neighbors_v2madedirectionandedge_typesrequired in PR-V2-1 and immediately got more reliable model behaviour. AddingLiteral[...]types is the same idea applied at the type level — schema-grounded decoding gets a hard structural cue, not just prose.Parameters affected
1.
neighbors.directionToday (mcp_v2.py / server.py):
Should be:
Why now: weakest models occasionally emit
direction="upstream","downstream","forward","backward","both". All currently fail with aKuzuGraphCypher error or empty result rather than a parse-time rejection.2.
find.kindToday:
Should be:
Why now: the closest valid-but-wrong values are
"method","class","endpoint","call"— all of which a model could plausibly emit. Today these silently produce empty results because_dispatchfalls through to a default branch.3.
search.tableToday:
Should be:
Why now: models occasionally emit
"code","source","docs","properties"— none currently raise a parse error.4. (Bonus)
neighbors.edge_typesitemsToday:
Should be:
Why now: the 9 valid edge labels are now enumerated verbatim in
_INSTRUCTIONS(PR #54), but a model can still emit"calls"(lowercase),"INHERITS","USES". Today these reach the Cypher query and either return empty (case mismatch) or raise an error (unknown rel type). WithLiteral, FastMCP rejects at parse time with a useful message naming the valid values.Implementation notes
mcp_v2.py@validate_callalready runs Pydantic validation;Literal[...]integrates natively, no extra glue.Field(description=...)strings should be shortened when the type itself enumerates valid values — redundant prose can confuse weak models more than it helps. Keep the description focused on semantics ("predecessors vs successors"), let the type carry the taxonomy.enum(not a free-formstring) — past schema flattening work (PR feat: tolerate JSON-encoded NodeFilter in MCP search/find/neighbors #55) showed FastMCP can collapse nested types unexpectedly. Worth a one-test schema-introspection assertion.Definition of done
neighbors.directiontyped asLiteral["in","out"]find.kindtyped asLiteral["symbol","route","client"]search.tabletyped asLiteral["java","sql","yaml","all"]neighbors.edge_typesitems typed viaEdgeType = Literal[...]tests/test_mcp_v2.pyadds 4 negative tests assertingValidationErroron a bad enum value for each parameterenumarray on the relevant property (locks the gain against future FastMCP/Pydantic behaviour changes)Field(description=...)strings — shortened where the type now carries the taxonomy+5tests (4 negative + 1 schema)Out of scope
direction/kind/tabletoEnumclasses —Literalis lighter, FastMCP-native, and produces the same JSON Schema enum.edge_types,"upstream"→"in", etc.). The point ofLiteralis to fail loudly with a useful message, not to silently accept synonyms. Synonym tolerance is a separate, more complicated design call.find.filter/search.filter— those were the focus of PR feat: tolerate JSON-encoded NodeFilter in MCP search/find/neighbors #55 and have a separate tolerance design (object/string/null union).Suggested rollout
Single Cursor task PR, additive type-tightening only, no schema delta, no ontology bump. Branch name suggestion:
feat/mcp-v2-literal-types. ~30 lines changed acrossmcp_v2.py+server.py, plus tests.Related
neighborssymbol_kind/symbol_kindsto disambiguate class/method/interface/enum withinfind("symbol", ...)#56 — adjacent v2.x type-tightening (symbol_kindfield on NodeFilter)