fastapi-gql-mcp 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. fastapi_gql_mcp-0.4.0/.github/workflows/ci-skip.yml +26 -0
  2. fastapi_gql_mcp-0.4.0/.github/workflows/ci.yml +107 -0
  3. fastapi_gql_mcp-0.4.0/.github/workflows/gh-pages.yml +42 -0
  4. fastapi_gql_mcp-0.4.0/.github/workflows/publish.yml +41 -0
  5. fastapi_gql_mcp-0.4.0/.gitignore +15 -0
  6. fastapi_gql_mcp-0.4.0/CHANGELOG.md +262 -0
  7. fastapi_gql_mcp-0.4.0/Comparison/README.md +104 -0
  8. fastapi_gql_mcp-0.4.0/Comparison/bench/env_ours/pyproject.toml +14 -0
  9. fastapi_gql_mcp-0.4.0/Comparison/bench/env_theirs/pyproject.toml +16 -0
  10. fastapi_gql_mcp-0.4.0/Comparison/bench/merge_results.py +81 -0
  11. fastapi_gql_mcp-0.4.0/Comparison/bench/results.json +252 -0
  12. fastapi_gql_mcp-0.4.0/Comparison/bench/results_ours.json +142 -0
  13. fastapi_gql_mcp-0.4.0/Comparison/bench/results_theirs.json +72 -0
  14. fastapi_gql_mcp-0.4.0/Comparison/bench/run_ours.py +168 -0
  15. fastapi_gql_mcp-0.4.0/Comparison/bench/run_theirs.py +146 -0
  16. fastapi_gql_mcp-0.4.0/Comparison/bench/shared_app.py +130 -0
  17. fastapi_gql_mcp-0.4.0/Comparison/index.html +460 -0
  18. fastapi_gql_mcp-0.4.0/PKG-INFO +327 -0
  19. fastapi_gql_mcp-0.4.0/README.md +310 -0
  20. fastapi_gql_mcp-0.4.0/demo/__init__.py +0 -0
  21. fastapi_gql_mcp-0.4.0/demo/__main__.py +27 -0
  22. fastapi_gql_mcp-0.4.0/demo/app.py +313 -0
  23. fastapi_gql_mcp-0.4.0/demo/jwt_passthrough.py +168 -0
  24. fastapi_gql_mcp-0.4.0/demo/mcp_walkthrough.py +104 -0
  25. fastapi_gql_mcp-0.4.0/demo/server.py +31 -0
  26. fastapi_gql_mcp-0.4.0/examples/notes_oauth/.env.example +12 -0
  27. fastapi_gql_mcp-0.4.0/examples/notes_oauth/README.md +166 -0
  28. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/__init__.py +0 -0
  29. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/__main__.py +37 -0
  30. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/auth_routes.py +171 -0
  31. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/config.py +27 -0
  32. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/credentials.py +53 -0
  33. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/main.py +63 -0
  34. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/mcp_oauth.py +85 -0
  35. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/models.py +45 -0
  36. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/notes_routes.py +90 -0
  37. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/observability.py +48 -0
  38. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/session.py +50 -0
  39. fastapi_gql_mcp-0.4.0/examples/notes_oauth/app/store.py +23 -0
  40. fastapi_gql_mcp-0.4.0/examples/notes_oauth/pyproject.toml +34 -0
  41. fastapi_gql_mcp-0.4.0/examples/notes_oauth/scripts/smoke.py +105 -0
  42. fastapi_gql_mcp-0.4.0/examples/otel_smoke.md +117 -0
  43. fastapi_gql_mcp-0.4.0/examples/otel_smoke.py +124 -0
  44. fastapi_gql_mcp-0.4.0/pyproject.toml +60 -0
  45. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/__init__.py +21 -0
  46. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/depth_guard.py +94 -0
  47. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/domains.py +146 -0
  48. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/graphiql.py +82 -0
  49. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/handler.py +183 -0
  50. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/http_api.py +89 -0
  51. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/invoker.py +336 -0
  52. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/mcp/__init__.py +0 -0
  53. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/mcp/errors.py +38 -0
  54. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/mcp/progressive_tools.py +274 -0
  55. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/mcp/server.py +274 -0
  56. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/mcp/tools.py +178 -0
  57. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/naming.py +35 -0
  58. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/scalars.py +110 -0
  59. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/scanner.py +395 -0
  60. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/schema_builder.py +290 -0
  61. fastapi_gql_mcp-0.4.0/src/fastapi_gql_mcp/type_builder.py +487 -0
  62. fastapi_gql_mcp-0.4.0/tests/__init__.py +0 -0
  63. fastapi_gql_mcp-0.4.0/tests/test_depth_guard.py +155 -0
  64. fastapi_gql_mcp-0.4.0/tests/test_domains.py +113 -0
  65. fastapi_gql_mcp-0.4.0/tests/test_example_public_api.py +39 -0
  66. fastapi_gql_mcp-0.4.0/tests/test_graphiql.py +182 -0
  67. fastapi_gql_mcp-0.4.0/tests/test_handler_schema.py +371 -0
  68. fastapi_gql_mcp-0.4.0/tests/test_invoker.py +343 -0
  69. fastapi_gql_mcp-0.4.0/tests/test_json_passthrough.py +102 -0
  70. fastapi_gql_mcp-0.4.0/tests/test_mcp_progressive.py +162 -0
  71. fastapi_gql_mcp-0.4.0/tests/test_mcp_simple.py +429 -0
  72. fastapi_gql_mcp-0.4.0/tests/test_mutation_semantics.py +70 -0
  73. fastapi_gql_mcp-0.4.0/tests/test_naming.py +38 -0
  74. fastapi_gql_mcp-0.4.0/tests/test_oauth_flow.py +234 -0
  75. fastapi_gql_mcp-0.4.0/tests/test_otel_propagation.py +124 -0
  76. fastapi_gql_mcp-0.4.0/tests/test_passthrough_headers.py +275 -0
  77. fastapi_gql_mcp-0.4.0/tests/test_scanner.py +354 -0
  78. fastapi_gql_mcp-0.4.0/tests/test_type_builder.py +257 -0
  79. fastapi_gql_mcp-0.4.0/uv.lock +2529 -0
@@ -0,0 +1,26 @@
1
+ # Companion to ci.yml. The real CI is path-filtered, so a PR that changes
2
+ # only non-code files (README, CHANGELOG, Comparison, etc.) skips ci.yml
3
+ # entirely — leaving the required "CI All Green" check stuck in "Expected"
4
+ # forever and blocking the PR. This workflow runs on the INVERSE path filter
5
+ # and reports the same "CI All Green" check as passing, so such PRs can
6
+ # merge. On a mixed PR both workflows run; both report green, and a real
7
+ # failure still fails the same-named check, so nothing is masked.
8
+ name: Test
9
+
10
+ on:
11
+ pull_request:
12
+ paths-ignore:
13
+ - 'src/fastapi_gql_mcp/**'
14
+ - 'tests/**'
15
+ - 'demo/**'
16
+ - 'examples/**'
17
+ - 'pyproject.toml'
18
+ - 'uv.lock'
19
+
20
+ jobs:
21
+ ci-all-green:
22
+ name: CI All Green
23
+ runs-on: ubuntu-latest
24
+ steps:
25
+ - name: No code paths changed
26
+ run: echo "No code paths changed — real CI skipped; reporting green."
@@ -0,0 +1,107 @@
1
+ name: Test
2
+
3
+ on:
4
+ pull_request:
5
+ paths:
6
+ - 'src/fastapi_gql_mcp/**'
7
+ - 'tests/**'
8
+ - 'demo/**'
9
+ - 'examples/**'
10
+ - 'pyproject.toml'
11
+ - 'uv.lock'
12
+ push:
13
+ branches: [master]
14
+ paths:
15
+ - 'src/fastapi_gql_mcp/**'
16
+ - 'tests/**'
17
+ - 'demo/**'
18
+ - 'examples/**'
19
+ - 'pyproject.toml'
20
+ - 'uv.lock'
21
+
22
+ jobs:
23
+ test:
24
+ runs-on: ubuntu-latest
25
+ strategy:
26
+ # One version failing must not cancel the others: a canceled job shows
27
+ # as failed even when its tests passed (observed: 3.13 finished green,
28
+ # then got canceled by 3.10's failure with fail-fast's default true).
29
+ fail-fast: false
30
+ matrix:
31
+ python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
32
+
33
+ steps:
34
+ - uses: actions/checkout@v5
35
+
36
+ - name: Set up Python ${{ matrix.python-version }}
37
+ uses: actions/setup-python@v6
38
+ with:
39
+ python-version: ${{ matrix.python-version }}
40
+
41
+ - name: Set up uv
42
+ uses: astral-sh/setup-uv@v7
43
+ with:
44
+ enable-cache: true
45
+
46
+ - name: Install dependencies
47
+ run: uv sync --all-extras
48
+
49
+ - name: Run linter
50
+ run: uv run ruff check src tests
51
+
52
+ - name: Run type checker
53
+ run: uv run mypy src
54
+
55
+ - name: Test with pytest
56
+ run: uv run pytest tests/ -v
57
+
58
+ # Guards the demo (and examples' syntax) against framework API drift. The
59
+ # demo is the framework's first real consumer; if a public API is renamed or
60
+ # removed, this job fails on the PR instead of shipping a demo that only
61
+ # breaks at run time. Critical: uv sync installs the WORKING-COPY framework
62
+ # (editable), so the demo is verified against the code in this PR — not the
63
+ # PyPI release. examples/notes_oauth is a standalone project with its own
64
+ # dependency set, so it is guarded here by compileall (syntax) and by
65
+ # tests/test_example_public_api.py in the test job (public-API-only imports).
66
+ demo-guard:
67
+ runs-on: ubuntu-latest
68
+ steps:
69
+ - uses: actions/checkout@v5
70
+
71
+ - name: Set up Python
72
+ uses: actions/setup-python@v6
73
+ with:
74
+ python-version: "3.13"
75
+
76
+ - name: Set up uv
77
+ uses: astral-sh/setup-uv@v7
78
+ with:
79
+ enable-cache: true
80
+
81
+ - name: Install working-copy framework + extras
82
+ run: uv sync --all-extras
83
+
84
+ - name: Demo app builds against working-copy framework
85
+ run: |
86
+ uv run python -c "import demo.app; print(f'demo builds: {len(demo.app.app.routes)} routes')"
87
+
88
+ - name: Compile all example sources
89
+ run: uv run python -m compileall -q examples
90
+
91
+ # Single required-status gate. Branch protection should require this ONE
92
+ # check ("CI All Green") instead of each job (the matrix makes per-job names
93
+ # awkward). It aggregates the real jobs and fails if any did not succeed.
94
+ # A mirror job of the same name lives in ci-skip.yml: when a PR touches no
95
+ # code paths this workflow is skipped entirely, so the mirror reports the
96
+ # same check as green and docs-only PRs are never blocked on a check that
97
+ # path filtering skipped.
98
+ ci-all-green:
99
+ name: CI All Green
100
+ if: always()
101
+ needs: [test, demo-guard]
102
+ runs-on: ubuntu-latest
103
+ steps:
104
+ - name: Verify required jobs succeeded
105
+ run: |
106
+ echo "test=${{ needs.test.result }} demo-guard=${{ needs.demo-guard.result }}"
107
+ [ "${{ needs.test.result }}" = "success" ] && [ "${{ needs.demo-guard.result }}" = "success" ]
@@ -0,0 +1,42 @@
1
+ # Deploys the Comparison page (benchmark visualizations) as a static GitHub
2
+ # Pages site. nexusx deploys mkdocs docs the same way; here the "site" is a
3
+ # single self-contained Comparison/index.html, so the native Pages actions
4
+ # are enough — no build step. Requires Pages enabled in repo settings with
5
+ # source "GitHub Actions".
6
+ name: GH Pages Deploy
7
+
8
+ on:
9
+ push:
10
+ branches: [master]
11
+ paths:
12
+ - 'Comparison/**'
13
+
14
+ permissions:
15
+ contents: read
16
+ pages: write
17
+ id-token: write
18
+
19
+ concurrency:
20
+ group: pages
21
+ cancel-in-progress: true
22
+
23
+ jobs:
24
+ deploy:
25
+ runs-on: ubuntu-latest
26
+ environment:
27
+ name: github-pages
28
+ url: ${{ steps.deployment.outputs.page_url }}
29
+ steps:
30
+ - uses: actions/checkout@v5
31
+
32
+ - name: Configure Pages
33
+ uses: actions/configure-pages@v5
34
+
35
+ - name: Upload Comparison page
36
+ uses: actions/upload-pages-artifact@v3
37
+ with:
38
+ path: Comparison
39
+
40
+ - name: Deploy
41
+ id: deployment
42
+ uses: actions/deploy-pages@v4
@@ -0,0 +1,41 @@
1
+ name: Publish to PyPI via uv
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ push:
6
+ tags:
7
+ - "v*"
8
+
9
+ jobs:
10
+ publish:
11
+ runs-on: ubuntu-latest
12
+
13
+ permissions:
14
+ contents: write
15
+
16
+ steps:
17
+ - name: Checkout repository
18
+ uses: actions/checkout@v5
19
+
20
+ - name: Set up uv
21
+ uses: astral-sh/setup-uv@v7
22
+
23
+ - name: Set up Python
24
+ uses: actions/setup-python@v6
25
+ with:
26
+ python-version: "3.13"
27
+
28
+ - name: Build the package
29
+ run: uv build
30
+
31
+ - name: Publish to PyPI
32
+ run: uv publish --token ${{ secrets.PYPI_PUBLISHER }}
33
+
34
+ - name: Create GitHub Release
35
+ uses: softprops/action-gh-release@v2
36
+ with:
37
+ body: See [CHANGELOG](https://github.com/klr-pattern/fastapi-gql-mcp/blob/master/CHANGELOG.md) for details.
38
+ generate_release_notes: true
39
+ files: |
40
+ dist/*.tar.gz
41
+ dist/*.whl
@@ -0,0 +1,15 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ .pytest_cache/
7
+ dist/
8
+ .DS_Store
9
+ .claude/
10
+
11
+ # consumer example: local env + resolved lock stay untracked
12
+ examples/*/.env
13
+ examples/*/uv.lock
14
+ examples/*/.venv/
15
+ Comparison/bench/*/uv.lock
@@ -0,0 +1,262 @@
1
+ # Changelog
2
+
3
+ ## 0.4.0 (2026-10-04)
4
+
5
+ ### Added
6
+
7
+ - **Observability (OpenTelemetry), one waterfall per query**: the bridge
8
+ injects W3C trace context (`traceparent`/`tracestate`/`baggage`) into
9
+ every in-process route call — bridge-generated, so it flows regardless of
10
+ `passthrough_headers`, and is a no-op without an SDK (new core dependency
11
+ `opentelemetry-api`, non-recording by default). A `graphql.execute` span
12
+ covers the GraphQL orchestration layer; route-call timeouts and
13
+ concurrency queue waits surface as span events (`route.timeout`,
14
+ `route.queue`). With fastmcp's tool spans and FastAPI >= 0.142's native
15
+ route spans, one MCP query now lands as
16
+ `tools/call > graphql.execute > GET /route` in a single trace (previously
17
+ the route spans were orphan traces). Regression-locked by
18
+ `tests/test_otel_propagation.py`; walkthrough in
19
+ `examples/otel_smoke.md`.
20
+
21
+ - **`max_depth` (default 10, `None` disables)** on
22
+ `RouterGraphQLHandler` / `RouterMCP`: recursive models make selection-set
23
+ nesting unbounded and an MCP caller is an LLM that can emit runaway
24
+ documents — overly deep ones are now rejected before execution with a
25
+ validation-style error. Fragment spreads resolve inline (spreading a
26
+ deep query across fragments cannot hide it; cyclic spreads are
27
+ rejected), inline fragments are transparent, and the root selection set
28
+ counts as depth 1. `validation_rules=` on the handler passes extra
29
+ graphql-core validation rules through to `graphql()` for anything
30
+ policy-shaped. README gained a "Hardening the bridge" section covering
31
+ these knobs plus the fastmcp rate-limiting / response-limiting
32
+ middleware (per-client by default) for the MCP face.
33
+
34
+ - **`max_concurrency` (default 16, `None` disables)** on
35
+ `RouterGraphQLHandler` / `RouterMCP`: a bound on in-flight route calls
36
+ across all queries. Sibling GraphQL fields resolve concurrently, so one
37
+ wide query fans out into parallel ASGI calls that could hammer the
38
+ wrapped app's upstream (DB, external APIs); the invoker-global semaphore
39
+ bounds that fan-out. The slot is acquired inside the timeout window, so
40
+ queueing time counts against `request_timeout` — a call waiting for a
41
+ slot cannot outlive its own deadline.
42
+
43
+ - **`request_timeout` (default 30s, `None` disables)** on
44
+ `RouterGraphQLHandler` / `RouterMCP`, threaded to the invoker. Enforced
45
+ with `asyncio.wait_for` and surfaced as a field-level `TIMEOUT` error
46
+ (http_status 504) — see Fixed below for why the httpx timeout alone
47
+ could never fire.
48
+
49
+ - **`handler.skips`**: the scanner's skip report (path/method/reason per
50
+ excluded route) is now caller-visible instead of log-only — CI can
51
+ assert `skips == []` (or an expected set) so a route silently falling
52
+ out of the schema fails the build. Completes the scanner's day-one
53
+ "the caller decides whether skips are acceptable" contract.
54
+
55
+ - GraphiQL shows an actionable offline notice (10s) when the esm.sh CDN
56
+ is unreachable, pointing at the raw `POST /graphql` endpoint.
57
+
58
+ ### Fixed
59
+
60
+ - **max_depth no longer rejects introspection**: `__`-prefixed meta-fields
61
+ don't count toward the depth guard — GraphiQL/codegen/IDE plugins ship a
62
+ fixed ~15-deep introspection document, which the default `max_depth=10`
63
+ rejected ("Error fetching schema" on a stock GraphiQL page). The guard
64
+ still bounds runaway DATA selections.
65
+
66
+ - `RouteInvoker(timeout=...)` was dead configuration: httpx's
67
+ `ASGITransport` never enforces timeouts (in-process calls bypass
68
+ httpcore) — a route could hang forever regardless of the setting.
69
+ Enforcement now lives in `asyncio.wait_for` inside `invoke()`.
70
+
71
+ - `content-type` / `accept` are refused by `filter_passthrough_headers`
72
+ even when whitelisted: a forwarded `content-type` would retype the JSON
73
+ body request (text/plain + json body → FastAPI 422). Custom headers
74
+ still forward as before.
75
+
76
+ ### Changed
77
+
78
+ - Progressive disclosure caches each domain's SDL fragment (the schema is
79
+ immutable after build; agents re-explore the same domain often), and
80
+ `DomainRegistry` answers `children()` from a precomputed index instead
81
+ of a full node scan per `list_domains` call.
82
+
83
+ - `mount_to(auth_at_root=True)` now carries an explicit checklist comment
84
+ of the fastmcp private surface it relies upon (per-route auth guard,
85
+ app-level auth/request-context middleware, well-known routes) — the
86
+ things to re-verify on any fastmcp major bump.
87
+
88
+ - **JSON pass-through for dynamic shapes**: endpoints and fields annotated
89
+ `dict`, `dict[K, V]`, `Any` (and `list`s / model fields thereof) bridge
90
+ as the `JSON` scalar instead of being skipped — an author declaring a
91
+ dynamic shape gets reachability, not invisibility. The line drawn: an
92
+ explicit `dict`/`Any` annotation passes through; a route with no
93
+ annotation and no `response_model` still skips (no contract). Both
94
+ directions: a `JSON` input argument lands as the raw request body.
95
+
96
+ - **`RouterMCP(auth=...)`**: optional fastmcp auth provider (e.g.
97
+ `GitHubProvider`) passed through to `FastMCP` untouched — the MCP
98
+ endpoint then speaks OAuth 2.1 (401 discovery, DCR, PKCE) and clients'
99
+ Bearer tokens travel into route calls via `passthrough_headers` like any
100
+ other caller's. The bridge verifies nothing. When `auth` is set,
101
+ `mount_to` also re-exposes the provider's `/.well-known/*` discovery
102
+ routes at the host app's root — the 401 challenge advertises them there
103
+ (RFC 8414), but the mount alone would shift them under the mount path.
104
+ `mount_to(..., auth_at_root=True)` goes further: MCP endpoint at `path`,
105
+ OAuth/discovery routes at the host root — for reusing an IdP app whose
106
+ registered callback URL lives at the root domain (subdirectory
107
+ redirect_path). Implemented by splicing the wrapped app's routes (host
108
+ routes keep precedence) and copying its middleware stack wholesale — a
109
+ plain `Mount("")` catch-all would silently shadow host routes added
110
+ later, and dropping the app-level middleware (auth verification,
111
+ request-context capture) silently 401s every request.
112
+
113
+ - **Consumer example `examples/notes_oauth`**: a full Notes app behind
114
+ GitHub OAuth — browser session cookie, `POST /auth/token` Bearer, and
115
+ MCP OAuth 2.1 login (Claude Code) as three interchangeable credential
116
+ carriers, with the fastmcp GitHub proxy reusing the app's OAuth callback
117
+ via a redirect subdirectory (`auth_at_root`). Guarded by
118
+ `tests/test_example_public_api.py`: examples import the public API only.
119
+
120
+ ### Fixed
121
+
122
+ - Python 3.10 support for recursive Pydantic models: CPython < 3.11 leaves
123
+ ``list["Node"]`` with the plain string inside the PEP 585 generic (only
124
+ ``typing.Union`` converts str args to ``ForwardRef``), and every evaluator
125
+ only evaluates ``ForwardRef`` — so ``FieldInfo.annotation`` stayed
126
+ unresolved and recursive models were skipped as unsupported types on 3.10
127
+ while working on 3.11+. The type builder now rewraps string args as
128
+ ``ForwardRef`` (leaving ``Literal`` values untouched — those strings are
129
+ values, not types) and evaluates them against the model's namespaces,
130
+ seeding the namespace with the model's own name (the enclosing scope binds
131
+ a class name only after the class body runs, so neither module globals
132
+ nor pydantic's parent-namespace snapshot can resolve a self-reference).
133
+
134
+ - The demo app imported ``datetime.UTC`` (Python 3.11+), failing import on
135
+ 3.10; it now uses ``timezone.utc``.
136
+
137
+ - A leaf field colliding with a child domain segment inside the same group
138
+ (e.g. a `catalog` endpoint tagged `shop` plus routes tagged
139
+ `shop:catalog`) was silently dropped: the child group field overwrote
140
+ it in the object type, with no warning — and the progressive-disclosure
141
+ index kept advertising the ghost field. Same-named leaves already failed
142
+ fast with `DuplicateFieldError`; leaf-vs-subdomain collisions now fail
143
+ fast the same way, naming both claimants.
144
+
145
+ - `mode="auto"` counted routes via `isinstance(app.routes, APIRoute)`,
146
+ which counts ZERO on FastAPI >= 0.142 (include_router results are
147
+ wrapped in `_IncludedRouter`) — a 30-route app stayed in simple mode,
148
+ handing agents one giant SDL. It also ignored `include`/`exclude`: an
149
+ app narrowed to 3 schema routes still switched to progressive. The
150
+ threshold decision now uses the scanned route count (what actually
151
+ enters the schema).
152
+
153
+ - Single non-model body parameters (e.g. `payload: dict[str, Any]`) were
154
+ wrongly wrapped as `{"payload": ...}` — FastAPI gives a lone body param
155
+ the WHOLE body unless `Body(embed=True)` or multiple body params are
156
+ involved. `_body_embeds` now replicates FastAPI's predicate exactly.
157
+
158
+ ### Changed
159
+
160
+ - **`headers_provider` removed; `passthrough_headers` defaults to
161
+ `("authorization",)`** (breaking). Credentials now have a single source —
162
+ the caller: each MCP/GraphQL client connects with its own credentials and
163
+ the bridge forwards them (case-insensitive whitelist; an explicitly empty
164
+ list disables forwarding). The server-side provider (and the
165
+ credential-amplification risk it created when mounted publicly) is gone;
166
+ machines without a user context configure the service credential on the
167
+ MCP client side, or use `handler.execute(..., headers=...)` directly.
168
+ With no HTTP request context (in-memory client) protected routes simply
169
+ answer 401.
170
+
171
+ - **`RouterMCP.run()` is HTTP-only** (streamable HTTP with `host`/`port`
172
+ parameters, default `127.0.0.1:8000`). The wrapped FastAPI app is a
173
+ service whose routes speak HTTP, and per-caller credential passthrough
174
+ needs an HTTP request context that stdio has no notion of — so the stdio
175
+ transport and the `demo.mcp_stdio` entry point are removed (breaking).
176
+ Use `mount_to(app, "/mcp")` to serve MCP on the app's own port.
177
+
178
+ ## 0.3.0 (2026-10-03)
179
+
180
+ Schema shape + documentation wave (breaking).
181
+
182
+ ### Changed
183
+
184
+ - **Renamed from `routerql` to `fastapi-gql-mcp`** (package
185
+ `fastapi_gql_mcp`): the distribution name now carries both FastAPI and MCP.
186
+ Exception prefixes `RouterQL*` became `GQLMCP*`; `RouterMCP` /
187
+ `RouterGraphQLHandler` keep their Router-based names.
188
+
189
+ ### Changed
190
+
191
+ - **Domain-grouped schema (UseCaseService-style hierarchy)**: fields live
192
+ under their tag domain tree — a route tagged `shop:catalog` answers at
193
+ `{ shop { catalog { list_products } } }`. Untagged routes join the domain of
194
+ their first path segment. Domain SDL fragments now show the exact grouped
195
+ address an agent should query.
196
+ - **GraphQL field names now come from the endpoint function name** (e.g.
197
+ `async def get_user` → `get_user`) instead of being reconstructed from the
198
+ URL path + verb. Path/query/body parameters still become the field's
199
+ arguments. Two routes sharing a function name fail fast with
200
+ `DuplicateFieldError` (previously the `_by_{param}` suffix silently
201
+ disambiguated collection/item pairs).
202
+
203
+ ### Added
204
+
205
+ - Upgraded the optional `mcp` extra to **fastmcp 4.x** (from 3.1): zero code
206
+ changes needed — `FastMCP` construction, tool registration, `http_app`
207
+ mounting and `run()` all carried over. Verified end-to-end (tools, queries,
208
+ mutations over streamable HTTP) on 4.0.10.
209
+ - Mutation execution semantics pinned by tests: cross-domain writes are
210
+ serial (spec-guaranteed at the mutation root); same-domain writes run in
211
+ parallel. Mutation-only schemas now fail fast with guidance (GraphQL
212
+ requires a Query root).
213
+ - **Argument descriptions**: `Query()/Path()/Body(description=...)` metadata
214
+ maps onto GraphQL argument descriptions, completing the doc chain
215
+ (model docstrings → types, `Field(description)` → fields, endpoint
216
+ docstrings/`summary=` → fields).
217
+ - Runnable demo suite: `python -m demo` (all-in-one server, progressive mode),
218
+ `demo.mcp_stdio`, `demo.mcp_walkthrough`; full documentation coverage in the
219
+ demo app for inspection.
220
+
221
+ ## 0.2.0 (2026-10-03)
222
+
223
+ Feature wave over the 0.1 core.
224
+
225
+ ### Added
226
+
227
+ - **Progressive disclosure MCP tools** (4 layers over the tag tree):
228
+ `list_domains` → `list_queries(domain)` / `list_mutations(domain)` →
229
+ `get_query_schema(domain)` → `graphql_query`. Domain SDL fragments are
230
+ filtered sub-schemas (only reachable types). Discovery is scoped per domain;
231
+ execution always runs against the full schema. `mode="auto"` switches to
232
+ progressive above `progressive_threshold` (default 25, configurable).
233
+ - **Query Parameter Models**: a lone `Annotated[Model, Query()]` flattens into
234
+ individual alias-aware query arguments. Models mixed with plain query params
235
+ are skipped with a reason.
236
+ - **GraphiQL playground + GraphQL-over-HTTP**: `handler.mount_graphql(app)`
237
+ serves `/graphiql` (CDN build, explorer plugin) wired to `POST /graphql`
238
+ accepting `{query, variables, operationName}`.
239
+ - **`mutation_include`** glob whitelist: with `allow_mutation=True`, write
240
+ routes must match to become mutations.
241
+ - Restructured `demo/` nexusx-style.
242
+
243
+ ### Fixed
244
+
245
+ - Type descriptions no longer inherit BaseModel's docstring when a model has
246
+ none of its own.
247
+
248
+ ## 0.1.0 (2026-10-03)
249
+
250
+ Initial release.
251
+
252
+ - RouterScanner: routes → field metadata (verb mapping, dependency-tree param
253
+ flattening, SkipRecord taxonomy, tag domains).
254
+ - TypeBuilder: Pydantic → graphql-core (custom DateTime/Date/Time/UUID/Decimal
255
+ scalars, Literal/Enum, cycle-safe registries, alias-first naming).
256
+ - RouteInvoker: in-process ASGI execution with lazy lifespan management and
257
+ `headers_provider` credential passthrough; HTTP ≥ 400 → `GraphQLError`
258
+ with `extensions.code=HTTP_{status}`.
259
+ - SchemaBuilder + RouterGraphQLHandler on graphql-core standard execution;
260
+ route responses nullable per field so sibling results survive failures.
261
+ - RouterMCP: stdio/mount MCP server, `get_schema` + `graphql_query` /
262
+ `graphql_mutation` with operation-type guards and hint-chain envelopes.
@@ -0,0 +1,104 @@
1
+ # fastapi-gql-mcp vs fastapi-mcp 对照分析
2
+
3
+ > 对照对象:**fastapi-gql-mcp 0.3.0**(本仓库,FastAPI → GraphQL → MCP)vs
4
+ > **fastapi-mcp 0.4.0**([tadata-org/fastapi_mcp](https://github.com/tadata-org/fastapi_mcp) ⭐12k,FastAPI → MCP 直暴露)
5
+ >
6
+ > 完整可视化报告:[index.html](./index.html) · 基准数据与复现方法:[bench/](./bench/)
7
+ > 所有数字均为本机实测(2026-10-04),非估算。
8
+
9
+ ## 一句话结论
10
+
11
+ 两条路线解决同一个问题的不同侧面:**fastapi-mcp 把端点变成工具**(端点 = 工具,最小心智负担),
12
+ **fastapi-gql-mcp 把 API 变成一张可组合的类型化查询图**(schema = 契约,上下文经济性 + 组合能力)。
13
+ 小而稳的 API 用前者足够;API 规模增长、需要给 agent 省上下文、需要组合查询/字段投影/写操作门禁/OAuth 登录体验时,后者的架构优势开始兑现。
14
+
15
+ ## 架构差异(根源)
16
+
17
+ | | fastapi-mcp | fastapi-gql-mcp |
18
+ |---|---|---|
19
+ | 中间层 | 无(OpenAPI schema → 工具清单) | GraphQL schema(域树、类型、描述链) |
20
+ | 工具模型 | **每个端点一个工具**(N 工具) | **固定 2–6 个工具** + schema 即契约(simple/progressive 两模式) |
21
+ | 工具命名 | FastAPI operationId:`list_notes_api_notes_get` | 端点函数名:`list_notes` |
22
+ | 调用执行 | OpenAPI 参数 → 还原 HTTP 请求(ASGI 进程内) | GraphQL 执行 → 路由调用(ASGI 进程内) |
23
+ | 组合能力 | 无,一次调用 = 一个端点 | 一次 `graphql_query` 组合多域多字段、别名、字段投影 |
24
+
25
+ ## 实测关键数字(共享同一个 notes 应用,双环境对称驱动)
26
+
27
+ ### 上下文经济性(agent 需要吞下的工具目录 + schema,字节/4 ≈ tokens)
28
+
29
+ | 端点数 | fastapi-mcp 工具目录 | fastapi-gql-mcp 全量(工具+SDL) | fastapi-gql-mcp 渐进式(单域发现) |
30
+ |---|---|---|---|
31
+ | 5 | 685 tok | 796 tok | 1,239 tok |
32
+ | 10 | 1,219 tok | 943 tok | 1,280 tok |
33
+ | 25 | 2,835 tok | 1,181 tok | 1,281 tok |
34
+ | 50 | 5,542 tok | 1,581 tok | **1,281 tok(持平)** |
35
+ | 100 | **10,954 tok** | 2,381 tok | **1,281 tok(持平)** |
36
+
37
+ - fastapi-mcp 随端点数**线性增长**(100 端点 ≈ 11k tokens 只算工具目录)
38
+ - fastapi-gql-mcp simple 模式缓增(SDL 很紧凑),**渐进式模式上下文恒定**(单域按需发现)
39
+ - 诚实反例:**5 端点的小 API,fastapi-mcp 反而更省**(685 vs 796)—— 没有 N+1 组合需求时它的简单就是优势
40
+
41
+ ### 往返与响应体积
42
+
43
+ | 任务 | fastapi-mcp | fastapi-gql-mcp |
44
+ |---|---|---|
45
+ | "过滤笔记 + 统计" 组合任务 | **2 次工具调用**(2 个 agent 轮次) | **1 次** `graphql_query` |
46
+ | 同一列表(20 条笔记)响应 | 2,875 B(全量 + indent=2) | 全量 2,341 B / **投影后 610 B(4.7×↓)** |
47
+
48
+ ### 延迟(进程内微基准,200 次迭代)
49
+
50
+ | | p50 | p95 |
51
+ |---|---|---|
52
+ | fastapi-mcp 单调用 | **0.91 ms** | 1.00 ms |
53
+ | fastapi-gql-mcp 单查询 | 1.36 ms | 1.93 ms |
54
+
55
+ 诚实结论:**单次平凡调用他们更快**(无 GraphQL 执行层)。但真实 agent 成本由**轮次**主导
56
+ (每轮含 LLM 推理,秒级),1ms 级差距远小于"1 轮 vs 2 轮"的差别 —— 组合能力才是省时间的杠杆。
57
+
58
+ ## 定性差异(详见 index.html)
59
+
60
+ | 维度 | fastapi-mcp | fastapi-gql-mcp |
61
+ |---|---|---|
62
+ | 错误语义 | HTTP ≥400 → **整个工具调用失败** | **字段级置空** + `extensions.code=HTTP_401/404`,兄弟字段照常返回 |
63
+ | 写操作门禁 | 无概念(全部端点可写) | `allow_mutation` + `mutation_include` 白名单 + 工具级操作类型守卫 |
64
+ | 认证 | OAuth 发现/授权**代理** + 假 DCR;端点防护靠自带 FastAPI `Depends`;不验 token | `auth=` 完整 OAuth 2.1 代理(DCR+PKCE+consent+引用型 token+**端点门禁**);`passthrough_headers` 按调用者透传 |
65
+ | 传输 | SSE / streamable HTTP / stdio(可分离部署) | streamable HTTP(按调用者凭据透传需要 HTTP 上下文,stdio 已移除) |
66
+ | 人的入口 | 无 | GraphiQL + `POST /graphql`(同一 schema 服务人和 agent) |
67
+ | 生态兼容 | `mcp>=1.12` **无上界**,但在 mcp 2.x 上**直接崩溃**(`Server` 签名变更)——本基准被迫双环境 | `fastmcp<5` 锁上界,4.0.10 端到端验证 |
68
+ | 代码规模 | ~2.0k LOC,直接基于 mcp SDK | ~2.6k LOC,基于 graphql-core + fastmcp |
69
+
70
+ ## 规范演进:MCP 的 lazy 机制与本对照的关系(2026-10 核实)
71
+
72
+ 用户容易把几层东西混为一谈,分层核实如下(依据:本地 mcp SDK 2.3.0 内嵌协议类型 + 生态检索):
73
+
74
+ | 层 | 机制 | 状态 | 省不省 agent 上下文 |
75
+ |---|---|---|---|
76
+ | 核心规范 | `tools/list` 分页(`nextCursor`,2025-06-18 起) | 已发布 | **不省** —— 分页只是传输分块,agent 选工具仍需全量目录 |
77
+ | 核心规范 | `server/discover` + `cacheScope`(2026-07-28 修订) | 已发布 | 不减体积 —— 帮的是 **prompt cache 复用**(public/private 缓存语义) |
78
+ | 规范扩展 | **Tool Search 扩展**(`tools/search`,只留名称/摘要、按需拉取完整定义) | **draft,需客户端+服务端双边支持**,生态采纳进行中 | **省** —— 这是真正的"lazy 工具加载" |
79
+ | 库层 | fastmcp 4.x `SearchTransform`(Regex/BM25):整个目录折叠成 `search_tools` + `call_tool` 两个普通工具 | **今天可用**,任意 MCP 客户端(纯工具实现,无需扩展) | 省 |
80
+
81
+ **对本对照的含义(诚实修正)**:
82
+
83
+ 1. fastapi-mcp 的"目录线性增长"是**开箱即用的现状**,不是永久死刑 —— 他们可以自己实现 search 折叠(其裸 mcp SDK 1.x 无现成件),或等 Tool Search 扩展普及且客户端支持
84
+ 2. 我们库的**渐进式披露与上述 lazy 模式同型**,且是 plain-tools 实现 —— 不依赖任何扩展,今天在任何客户端上工作;即便工具目录问题未来被扩展彻底解决,**组合查询、字段投影、字段级错误隔离、写门禁仍只有 GraphQL 路线能给** —— 工具目录体积只是本对照的一个维度,不是全部
85
+ 3. fastmcp 的 `SearchTransform` 对我们属于"备用弹药":我们的目录本来就恒定在 2–6 个工具,无需折叠;若未来 simple 模式想给超小上下文选项,可叠加
86
+
87
+ ## 选型建议
88
+
89
+ - **选 fastapi-mcp**:端点少(<10)且稳定、只给 agent 用、想要零配置最快接入、认证已有 FastAPI 依赖兜底
90
+ - **选 fastapi-gql-mcp**:API 会长大;上下文预算紧;agent 需要一次拿到组合视图;需要字段投影控制响应体积;需要细粒度写门禁;需要 OAuth 登录的完整体验;顺便想让人类也用 GraphQL
91
+
92
+ ## 复现
93
+
94
+ 前提:把对照库克隆到本仓库旁边(`env_theirs` 以 path 依赖引用它):
95
+
96
+ ```bash
97
+ gh repo clone tadata-org/fastapi_mcp ../fastapi-mcp # 对照组源码
98
+ cd Comparison/bench
99
+ uv run --project env_ours python run_ours.py # 本库侧
100
+ uv run --project env_theirs python run_theirs.py # fastapi-mcp 侧
101
+ python3 merge_results.py # 合并 → results.json
102
+ ```
103
+
104
+ 共享目标应用在 `shared_app.py`(同一份代码接入两个框架 —— 这也是"对接两个框架"的桥接代码本体)。
@@ -0,0 +1,14 @@
1
+ [project]
2
+ name = "bench-env-ours"
3
+ version = "0.0.0"
4
+ description = "fastapi-gql-mcp side of the comparison benchmark"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "fastapi>=0.115",
8
+ "httpx>=0.27",
9
+ "asgi-lifespan>=2.1",
10
+ "fastapi-gql-mcp[mcp]>=0.3",
11
+ ]
12
+
13
+ [tool.uv.sources]
14
+ fastapi-gql-mcp = { path = "../../..", editable = true }
@@ -0,0 +1,16 @@
1
+ [project]
2
+ name = "bench-env-theirs"
3
+ version = "0.0.0"
4
+ description = "fastapi-mcp (tadata) side of the comparison benchmark"
5
+ requires-python = ">=3.10"
6
+ dependencies = [
7
+ "fastapi>=0.115",
8
+ "httpx>=0.27",
9
+ "fastapi-mcp>=0.4",
10
+ # fastapi-mcp 0.4.0 pins mcp>=1.12 with NO upper bound but breaks on
11
+ # mcp 2.x (lowlevel Server signature change) — pinned here so it runs.
12
+ "mcp>=1.12,<2",
13
+ ]
14
+
15
+ [tool.uv.sources]
16
+ fastapi-mcp = { path = "../../../../fastapi-mcp", editable = true }