Repository navigation
Releases: KLR-Pattern/fastapi-gql-mcp
Release list
v0.12.0
Added
search_fields(domain, query)(progressive mode): BM25 over field
names, descriptions and argument names — the shortcut for wide domains
where reading the whole fragment costs more than searching for the one
field you need. Field names come from endpoint function names (developer
vocabulary, keyword-dense), so lexical search lands well without
embeddings. Indexed lazily per domain, cached (schema is immutable).
Measured on a 324-route app: a 1,743-token domain fragment collapses to
a ~200-token search round trip.
v0.11.1
Docs
- Capability boundaries rewritten as the full input/output matrix:
a structured-inputs table (path convertors, query models, body shapes,
Depends, scalars, alias wire contract), two new degradation rows (unbound
TypeVar at a route boundary, unannotated body parameters), mixed-type
Literal in the skip list, and a "Conventions the bridge follows" table
collecting the FastAPI-side derivation rules (tags/domains, function
names, descriptions, requiredness per model family, exclude/deprecated,
optional headers, multi-verb priority, naming collisions).
Fixed
- TypeVar-degraded routes now carry the real reason in their SDL note.
Route-output classification runs ONCE at scan time
(scanner.OutputPlan) and every reader — startup notice, readiness
report, SDL field note — consumes the same plan, ending five verified
disagreements between the three former detectors: the misleading
"declares no response type" guidance on generic routes, first-match
audit vs stacked SDL notes, void+filter audited as degraded (a bodyless
response has nothing to filter), Any+filter showing two different
stories, and the filter⇒JSON decision smeared across four sites.
Changed
-
Input/output handling systematically reorganized (four refactors,
zero behavior change beyond the fix above):- type_builder's six near-clone field builders (model/TypedDict/
dataclass × input/output) unified onFieldSpec: three family
extractors produce normalized field records, one_build_fields
loop owns sanitize/wire-key/degrade/union/empty handling that was
copy-pasted six times (1052 → 1041 lines while gaining the
abstraction) _build_route_info(158 lines) decomposed into a gate sequence
(_FlatParams, five named gates,_route_params,_trial_route_types)
with_make_paramas the single ParamInfo/gname producer- wire-name/default/note helpers single-sourced (
request_wire_name,
output_wire_name,graphql_default,append_note); all three
cross-module private imports made public or moved home
- type_builder's six near-clone field builders (model/TypedDict/
-
Test suite reorganized into layers.
tests/now mirrors altitude
instead of git history:unit/(one module under test, no handler
execution),integration/(throughRouterGraphQLHandler),
e2e/(ASGI / fastmcp Client),guards/(meta pins), plus a shared
tests/support/package (sample models, app factories, MCP/HTTP
protocol helpers) that replaces ~19 copy-pasted factories and
per-file model redefinitions. Amake_handlerconftest fixture owns
handler lifecycle. Four assertion-level duplicates merged
(filter_passthrough_headers ×2, introspection, list-type, path-param
restatement); layered assertions deliberately kept. Ticket-wave labels
(G5-G11/H1/M1, P0-2, R1/R4, B1, O1/O2, D1) moved from names and
section markers into docstrings.
Added
- Seventeen gap tests lift coverage from 95.1% to 96.7%:
unknown-domain errors for the remaining progressive tools, fragment
arms of the unroll-limit walk, HEAD/OPTIONS-only routes, empty
TypedDict/dataclass input raises, output-side AliasChoices fallback,
input-side hyphenated TypedDict keys, dataclass field degradation on
both sides, non-JSON 2xx bodies, long error-body truncation, and
syntax errors on the unguarded parse path.
v0.11.0
Added
- Stdlib
@dataclassmaps onto real object/input types. A dataclass
response (or a dataclass field nested inside a model, and vice versa)
becomes aGraphQLObjectType/GraphQLInputObjectTypelike a
BaseModelorTypedDict: annotations fromget_type_hints, output
nullability from the annotation (defaults always materialize), input
requiredness and literal defaults from the field definitions
(default_factoryfields are optional without a GraphQL default —
FastAPI materializes them, a 422 surfaces as a field error).
ClassVar/InitVarnever become fields. FastAPI treats dataclasses as
first-class citizens on both sides, so validation and serialization ride
the native stack — the runtime path needed no changes. Recursive
dataclasses get the true-depth contract like any recursive type.
v0.10.0
Changed
-
Four deduplications around the recursion machinery. The NonNull/List
unwrap and the direct-self-reference predicate each have ONE definition
(recursive_expand.unwrap/is_direct_self_reference) shared by
type_builder's schema note andrecursive_edges, so the two detectors
cannot drift;UnsupportedFieldTypeErrorcarries a structured
field_reasonattribute that degraded-field reporting reads instead of
sniffing exception message text; the field-build memo lives once inside
the register-then-build shells instead of four per-builder cache
wrappers. -
Unmappable model fields degrade to raw JSON instead of skipping the
route (issue #3, General Fix Strategy #2). A field whose type has no
GraphQL mapping (metadata: SomeCustomClass) bridges as theJSON
scalar — the surrounding model keeps its structured, selectable fields,
and the degradation lands inreadiness().degraded_fieldsand the
startup warning, exactly like union fields always have. Input fields
degrade the same way (FastAPI validates whatever arrives; a 422 surfaces
as a field error). Unbound-TypeVar generics now degrade their field
(keeping the parameterize guidance) instead of skipping the route.
Skips remain only where no fallback can express the shape: top-level
response/parameter annotations with no mapping at all (-> bytes, a
bare custom class); a wholly-unusable nested type (an empty model)
rolls back transactionally and degrades its referencing field.
TypeBuilder.union_fieldsis renamed todegraded_fields(unions and
unmappable types, deduplicated per field+reason).
Added
-
Recursive fields return their true depth. A selection on a recursive
model field now means "the whole subtree": the agent's stopping selection
repeats as a template to the data's actual depth, at any position in the
document (the chain need not start at the root). Previously the finite
GraphQL document silently truncated recursive data — invisibly, since a
leaf'schildren: []is indistinguishable from a cut-off subtree, and
trees deeper thanmax_depthcould not be fetched in full at all. The
route already computed the complete tree (routes are called once; nested
resolution is pure projection), so unrolling merely removes the document
limitation.max_depthstill guards the document as written; fragments
in a recursive template and mutual recursion (A.b: B / B.a: A) are
consciously left unexpanded. The unroll ceiling is a function of the
process recursion budget (sys.getrecursionlimit() // 16, from a
measured ~12 frames per served level) — the limit never promises depth
the executor cannot deliver, and it scales automatically when an
operator raises the limit to serve deeper trees; data deeper than the
limit is served as deep as the document goes, with a definite error
naming the limit (data stays — truncation is never silent). The
recursive type's schema description carries a one-line contract ("full
subtree at true depth; your selection repeats per level") once per
type — local placement at ~15 tokens instead of per-field paragraphs,
so agents discover it from the SDL. -
instructions=passthrough onFastAPIMCP— the MCP protocol's
handshake usage guide, injected into the agent's context once per
connection;None(default) sends nothing. README documents how to
write one that saves discovery round-trips. -
stateless_http=Trueonmount_to()/run()— one transport per
request (no session affinity), for multi-worker / multi-pod deployments
behind non-sticky load balancers where stateful streamable HTTP sessions
404. Passed through to fastmcp'shttp_app/run_http_async. -
MCP tool annotations — discovery tools and
graphql_querynow carry
readOnlyHint,graphql_mutationcarriesdestructiveHint, letting
agents auto-run reads and confirm writes. -
exclude_deprecated=Truefilter — dropsdeprecated=Trueroutes at
the config level (silent drop, like path/tag filters); by default they
stay with their GraphQL-native@deprecatedmark.
Fixed
- A subselection-less recursive field is rejected, not crashed on.
Selecting a back-edge without a subselection (childrenwith no
{ ... }) is invalid GraphQL; it now comes back with the standard
"must have a selection of subfields" validation error like any other
field, instead of an uncaughtAssertionErrorsurfacing as an opaque
500. - Custom validation rules see the document the agent wrote. Unrolling
is a system behavior; caller-supplied rules (cost guards, complexity
caps) now validate the written document, so a 3-field recursive
selection is no longer rejected for having ~60x the fields the agent
asked for. The standard rule set still validates the expanded document
the executor actually runs. - Union-field degradation is recorded on every side. A union field
bridging to raw JSON lands inreadiness().degraded_fieldsand carries
its schema note whether it sits on a model's output, a model's input, a
TypedDict's output or a TypedDict's input — the audit no longer depends
on which side of the API the field happens to be on. - The excess walk is gated by the document. Edge-named keys inside a
JSON passthrough payload are invisible to the document and can no
longer flag a mixed query (recursive tree + colliding payload keys)
that truncated nothing. - The unroll limit is honest — the crash band is gone. The old floor
max(100, ...)sat above the measured ~82-level execution ceiling, so
trees between ~83 and 100 levels returnednullwith a bare "maximum
recursion depth exceeded" instead of data. The limit is now
sys.getrecursionlimit() // 16(the deepest walk it can produce stays
at ~3/4 of the measured ceiling) and is pinned per handler instance at
construction: raising the budget later serves deeper trees on NEW
handlers, while an existing one reports a definite error on data beyond
its pinned limit instead of silently truncating against cached,
shallower documents. - Excess detection is a proof, not a guess. The unrolled document's
innermost repetition is a probe: data occupying it proves deeper data
exists. Complete data leaves the probe empty and is never flagged (the
old depth-counting heuristic fired on exactly-complete trees), and
single-object recursive edges (next: LNode | None) count like list
edges — they used to truncate with no report at all. - Only stamped documents pay the per-response excess walk. Queries
touching no recursive field never ran it and can no longer pick up
false flags from JSON passthrough payloads whose keys collide with
edge names; the walk also reuses edge names computed once per handler
instead of rebuilding them per response. - Nested recursive chains unroll too. Stamping a chain end makes
graphql-core'svisitrebuild every ancestor of the edited node, so the
id-keyed chain-end table silently missed an outer chain end nested
inside another chain's template — two recursive types in one document
truncated invisibly (the very thing unrolling exists to prevent).
Detection and stamping now share one bottom-up pass: chain-end-ness is
decided from each node itself (its type context and its own
selections), so no marking state has to survive the node rebuilds. - A template alias reserving the back-edge's response key no longer
fails validation.children: nameinside the stopping selection
collided with the stamped subtree (children { ... }) on the same
response key, rejecting a legal query with ~100FieldsConflict
errors. Such chains now degrade to the written document — the agent's
own key choice wins — the same honest fallback as fragment-carrying
templates. - A failed model build no longer poisons the shared type cache (issue
#3, case 1). Object/input registration is now transactional: when a
field fails to map, the half-built type (and its name) rolls back out of
the caches. Previously the first route was skipped but the incomplete
type stayed cached, so a second route reusing the same model passed
scanning and theGraphQLSchemabuild then crashed for the WHOLE app
(TypeError: ... fields cannot be resolved) — one incompatible model
took every valid route down with it. Nested failures roll back every
recursion frame, and each reuse of a bad model now gets its own skip
record. set/frozensetmap onto GraphQL lists (issue #3, case 3).
Pydantic serializes sets to JSON arrays, soset[T]fields, parameters
and responses bridge as[T!]!exactly likelist[T]— previously the
route was skipped with "Cannot map typing.Set".- Enum members inside
Literalmap to their underlying scalar (issue
#3, case 2).Literal[Mode.A]now normalizesMode.AtoMode.A.value
and rides the existing Literal path (String/Int+ the
"Allowed values" description); mixed members like
Literal[Mode.A, "other"]work, and route-level Literal responses now
carry the allowed-values note in their field description, matching model
fields. Previously such routes were skipped with "Literal of Mode has no
GraphQL scalar". TypedDictmaps onto real object/input types (issue #3, case 6).
ATypedDictresponse (or a TypedDict field nested inside a Pydantic
model) becomes aGraphQLObjectType/GraphQLInputObjectTypelike a
BaseModel: field types fromget_type_hints, nullability from the
required/optional key sets (total=Falsekeys bridge as nullable — they
may be absent from the JSON), recursive TypedDicts resolve through the
same register-before-fields cycle handling, and failed builds roll back
transactionally. Detection usestyping_extensions.is_typeddict, which
recognizes classes declared via BOTHtyping.TypedDictand
typing_extensions.TypedDict(typing.is_typeddictmisses the l...
v0.9.0
Changed
-
RouterMCPrenamed toFastAPIMCP. The old name said Router while
the class wraps a whole FastAPI app and IS the MCP server;FastAPIMCP
names what it is and matches the package.RouterMCPremains as a
deprecated subclass (DeprecationWarning on construction, removed at
1.0) — existing imports keep working. -
(breaking)
RouterMCP.modeproperty renamed toresolved_mode—
it returns the mode actually in effect and never"auto", unlike the
modeconstructor argument; the old name invited
assert mcp.mode == "auto", which could never pass. -
(breaking)
ReadinessReport.from_scanremoved from the public
surface (added in 0.8.0): assembling a report required scan's internal
products (TypeBuilder), which callers cannot obtain — the method was
unusable externally and produced incomplete reports when forced.
ReadinessReportis now pure data (three tuples +ready); get one
fromRouterScanner(app, ...).readiness()orhandler.readiness(). -
handler.skipsdocstring now marks it a strict subset of
readiness().skipsand points toreadiness()for the full audit.
v0.8.0
See CHANGELOG for details.
Full Changelog: v0.7.0...v0.8.0
v0.7.0
See CHANGELOG for details.
Full Changelog: v0.6.0...v0.7.0
v0.6.0
See CHANGELOG for details.
Full Changelog: v0.5.0...v0.6.0
v0.5.0
See CHANGELOG for details.
Full Changelog: v0.4.0...v0.5.0
v0.4.0
See CHANGELOG for details.
Full Changelog: https://github.com/KLR-Pattern/fastapi-gql-mcp/commits/v0.4.0