Skip to content

Tighten MCP tool parameter types: use Literal[...] for direction / kind / table / edge_types #58

@HumanBean17

Description

@HumanBean17

Summary

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_types required 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")

Should be:

direction: Literal["in", "out"] = Field(..., description="Required. 'in' = predecessors (callers), 'out' = successors (callees). 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.

2. find.kind

Today:

kind: str = Field(..., description="symbol | route | client")

Should be:

kind: Literal["symbol", "route", "client"] = Field(..., description="Which graph table to search. 'symbol' = Java declarations (classes, methods, enums, etc.); 'route' = HTTP endpoints; 'client' = outbound HTTP/feign clients.")

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.

3. search.table

Today:

table: str = Field("all", description="java | sql | yaml | all")

Should be:

table: Literal["java", "sql", "yaml", "all"] = Field("all", description="Which content table to search. 'all' fuses results across java/sql/yaml.")

Why now: models occasionally emit "code", "source", "docs", "properties" — none currently raise a parse error.

4. (Bonus) neighbors.edge_types items

Today:

edge_types: list[str] = Field(..., min_length=1, description="...")

Should be:

EdgeType = Literal["EXTENDS","IMPLEMENTS","INJECTS","DECLARES","DECLARES_CLIENT","CALLS","EXPOSES","HTTP_CALLS","ASYNC_CALLS"]
edge_types: list[EdgeType] = Field(..., min_length=1, description="...")

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)
  • Suite stays green; expected +5 tests (4 negative + 1 schema)

Out of scope

  • 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.
  • Don't touch 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 across mcp_v2.py + server.py, plus tests.

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions