fastapi-gql-mcp 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,21 @@
1
+ """fastapi-gql-mcp: turn any FastAPI router into a GraphQL query layer + MCP server."""
2
+
3
+ from fastapi_gql_mcp.handler import GQLMCPConfigError, RouterGraphQLHandler
4
+ from fastapi_gql_mcp.invoker import RouteInvoker, filter_passthrough_headers
5
+ from fastapi_gql_mcp.mcp.errors import GQLMCPErrors
6
+ from fastapi_gql_mcp.mcp.server import RouterMCP
7
+ from fastapi_gql_mcp.scanner import RouteInfo, RouterScanner
8
+
9
+ __version__ = "0.4.0"
10
+
11
+ __all__ = [
12
+ "RouteInfo",
13
+ "RouteInvoker",
14
+ "RouterGraphQLHandler",
15
+ "RouterMCP",
16
+ "GQLMCPConfigError",
17
+ "GQLMCPErrors",
18
+ "RouterScanner",
19
+ "filter_passthrough_headers",
20
+ "__version__",
21
+ ]
@@ -0,0 +1,94 @@
1
+ """Reject overly deep GraphQL documents before execution.
2
+
3
+ Recursive models (a Node whose children are Nodes) make query depth
4
+ unbounded: graphql-core happily executes a thousand nested selections, and
5
+ an MCP caller is an LLM that can emit such a document. The guard measures
6
+ selection-set nesting (the root selection set is depth 1) with fragments
7
+ resolved inline and cycles rejected — spreading a deep query across
8
+ fragments cannot hide it.
9
+
10
+ Introspection meta-fields (``__``-prefixed) do not count: standard tooling
11
+ (GraphiQL, codegen, IDE plugins) ships a fixed, deep introspection document
12
+ — depth ~15 — whose shape is bounded by the schema itself, not by the
13
+ caller. The guard targets runaway DATA selections.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from graphql import GraphQLError
19
+ from graphql.language import (
20
+ DocumentNode,
21
+ FieldNode,
22
+ FragmentDefinitionNode,
23
+ FragmentSpreadNode,
24
+ InlineFragmentNode,
25
+ OperationDefinitionNode,
26
+ SelectionSetNode,
27
+ parse,
28
+ )
29
+
30
+
31
+ class _FragmentCycle(Exception):
32
+ """A fragment spread referencing itself (illegal GraphQL anyway)."""
33
+
34
+
35
+ def document_depth(document: DocumentNode) -> int:
36
+ """Deepest selection-set nesting across all operations (root = 1).
37
+
38
+ Fragment spreads resolve into their definition's selection set without
39
+ adding a level of their own (``{ ...F }`` with ``fragment F on T
40
+ { a { b } }`` is depth 2, same as the inlined form); a spread cycle
41
+ raises, since the real document would recurse forever.
42
+ """
43
+ fragments = {
44
+ d.name.value: d
45
+ for d in document.definitions
46
+ if isinstance(d, FragmentDefinitionNode)
47
+ }
48
+
49
+ def selection_depth(sel_set: SelectionSetNode, active: frozenset[str]) -> int:
50
+ best = 1
51
+ for sel in sel_set.selections:
52
+ if isinstance(sel, FieldNode):
53
+ if sel.name.value.startswith("__"):
54
+ # introspection meta-fields don't count toward depth —
55
+ # see module docstring
56
+ continue
57
+ if sel.selection_set is not None:
58
+ best = max(best, 1 + selection_depth(sel.selection_set, active))
59
+ elif isinstance(sel, InlineFragmentNode):
60
+ if sel.selection_set is not None:
61
+ best = max(best, selection_depth(sel.selection_set, active))
62
+ elif isinstance(sel, FragmentSpreadNode):
63
+ name = sel.name.value
64
+ if name in active:
65
+ raise _FragmentCycle
66
+ frag = fragments.get(name)
67
+ if frag is not None:
68
+ best = max(best, selection_depth(frag.selection_set, active | {name}))
69
+ return best
70
+
71
+ deepest = 0
72
+ for defn in document.definitions:
73
+ if isinstance(defn, OperationDefinitionNode) and defn.selection_set is not None:
74
+ deepest = max(deepest, selection_depth(defn.selection_set, frozenset()))
75
+ return deepest
76
+
77
+
78
+ def depth_error(query: str, max_depth: int) -> GraphQLError | None:
79
+ """A GraphQLError rejecting the document — too deep, cyclic fragments,
80
+ or unparsable (syntax errors surface through the same envelope graphql()
81
+ would use) — or None when the document is within bounds."""
82
+ try:
83
+ document = parse(query)
84
+ except GraphQLError as exc:
85
+ return exc
86
+ try:
87
+ depth = document_depth(document)
88
+ except _FragmentCycle:
89
+ return GraphQLError(
90
+ f"Document has cyclic fragment spreads (max_depth={max_depth})."
91
+ )
92
+ if depth > max_depth:
93
+ return GraphQLError(f"Query depth {depth} exceeds max_depth={max_depth}.")
94
+ return None
@@ -0,0 +1,146 @@
1
+ """Tag-based domain grouping for progressive disclosure.
2
+
3
+ Domains form a tree via the ``"a:b"`` separator convention (``tags=["billing:invoice"]``
4
+ → ``("billing", "invoice")``). The tree only shapes *discovery* (which slice of
5
+ the schema an agent looks at); execution always runs against the full schema.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from collections.abc import Sequence
12
+ from typing import TYPE_CHECKING
13
+
14
+ if TYPE_CHECKING:
15
+ from fastapi_gql_mcp.scanner import RouteInfo
16
+
17
+ _GENERAL: tuple[str, ...] = ("general",)
18
+ _CAMEL_BOUNDARY = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
19
+
20
+
21
+ def _sanitize_segment(segment: str) -> str:
22
+ snake = _CAMEL_BOUNDARY.sub("_", segment).replace("-", "_").lower()
23
+ return re.sub(r"[^a-z0-9_]", "_", snake) or "_"
24
+
25
+
26
+ def domains_for(tags: Sequence[str], path: str) -> frozenset[tuple[str, ...]]:
27
+ """Compute the domain paths a route belongs to.
28
+
29
+ Rules (in order):
30
+ 1. Each tag becomes one domain path, split on ``:``.
31
+ 2. Without tags, the first non-parameter path segment is the domain.
32
+ 3. Fallback: ``general``.
33
+ """
34
+ if tags:
35
+ return frozenset(
36
+ tuple(_sanitize_segment(seg) for seg in tag.split(":")) for tag in tags
37
+ )
38
+ for segment in path.split("/"):
39
+ if segment and not (segment.startswith("{") and segment.endswith("}")):
40
+ return frozenset({(_sanitize_segment(segment),)})
41
+ return frozenset({_GENERAL})
42
+
43
+
44
+ class DomainNode:
45
+ """One node of the domain tree aggregating its routes' field names.
46
+
47
+ Field names are keyed as ``(domain_path, field_name)`` pairs: names only
48
+ need to be unique WITHIN one domain group (GraphQL: within one object
49
+ type), so two domains may each carry their own ``get_user``.
50
+ """
51
+
52
+ def __init__(self, path: tuple[str, ...]) -> None:
53
+ self.path = path
54
+ self.query_fields: set[tuple[tuple[str, ...], str]] = set()
55
+ self.mutation_fields: set[tuple[tuple[str, ...], str]] = set()
56
+
57
+ @property
58
+ def name(self) -> str:
59
+ return self.path[-1]
60
+
61
+ def add(self, route: RouteInfo) -> None:
62
+ key = (self.path, route.field_name)
63
+ if route.is_mutation:
64
+ self.mutation_fields.add(key)
65
+ else:
66
+ self.query_fields.add(key)
67
+
68
+ def summary(self) -> dict[str, object]:
69
+ return {
70
+ "name": self.name,
71
+ "path": list(self.path),
72
+ "queries": len(self.query_fields),
73
+ "mutations": len(self.mutation_fields),
74
+ }
75
+
76
+
77
+ class DomainRegistry:
78
+ """Indexes routes by domain path and answers tree-shaped queries.
79
+
80
+ A route with several tags (or whose tag path has depth) is registered at
81
+ every level of each of its domain paths, so ``list_domains`` can expose
82
+ both coarse and fine groupings.
83
+ """
84
+
85
+ def __init__(self, routes: Sequence[RouteInfo]) -> None:
86
+ self._nodes: dict[tuple[str, ...], DomainNode] = {}
87
+ self._children: dict[tuple[str, ...], set[str]] = {}
88
+ for route in routes:
89
+ for domain in route.domains:
90
+ for depth in range(1, len(domain) + 1):
91
+ node = self._node(domain[:depth])
92
+ # Only the leaf level carries the field; parents aggregate
93
+ # counts through their subtree at query time.
94
+ if depth == len(domain):
95
+ node.add(route)
96
+ # Precomputed child index: children() answers in O(1) instead of
97
+ # scanning every node per list_domains call. Single-segment paths
98
+ # parent to () — the root's direct children are indexed too.
99
+ for node_path in self._nodes:
100
+ self._children.setdefault(node_path[:-1], set()).add(node_path[-1])
101
+
102
+ def _node(self, path: tuple[str, ...]) -> DomainNode:
103
+ node = self._nodes.get(path)
104
+ if node is None:
105
+ node = DomainNode(path)
106
+ self._nodes[path] = node
107
+ return node
108
+
109
+ def node(self, path: tuple[str, ...]) -> DomainNode | None:
110
+ return self._nodes.get(path)
111
+
112
+ def children(self, path: tuple[str, ...] = ()) -> list[str]:
113
+ """Direct child segment names under ``path``."""
114
+ return sorted(self._children.get(tuple(path), ()))
115
+
116
+ def subtree_fields(
117
+ self, path: tuple[str, ...]
118
+ ) -> tuple[set[tuple[tuple[str, ...], str]], set[tuple[tuple[str, ...], str]]]:
119
+ """All (domain path, field name) pairs at or below ``path``."""
120
+ queries: set[tuple[tuple[str, ...], str]] = set()
121
+ mutations: set[tuple[tuple[str, ...], str]] = set()
122
+ for node_path, node in self._nodes.items():
123
+ if node_path[: len(path)] == path:
124
+ queries |= node.query_fields
125
+ mutations |= node.mutation_fields
126
+ return queries, mutations
127
+
128
+ def summary(self) -> list[dict[str, object]]:
129
+ """Top-level domain summaries (for the ``list_domains`` MCP tool)."""
130
+ roots: set[tuple[str, ...]] = set()
131
+ for node_path in self._nodes:
132
+ roots.add(node_path[:1])
133
+ result: list[dict[str, object]] = []
134
+ for root in sorted(roots):
135
+ queries, mutations = self.subtree_fields(root)
136
+ children = self.children(root)
137
+ result.append(
138
+ {
139
+ "name": root[0],
140
+ "path": list(root),
141
+ "queries": len(queries),
142
+ "mutations": len(mutations),
143
+ **({"subdomains": children} if children else {}),
144
+ }
145
+ )
146
+ return result
@@ -0,0 +1,82 @@
1
+ """GraphiQL HTML template for fastapi-gql-mcp (CDN-loaded, no build step)."""
2
+
3
+ GRAPHIQL_HTML = """
4
+ <!DOCTYPE html>
5
+ <html lang="en">
6
+ <head>
7
+ <meta charset="utf-8" />
8
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
9
+ <title>GraphiQL - fastapi-gql-mcp</title>
10
+ <style>
11
+ body { margin: 0; }
12
+ #graphiql { height: 100dvh; }
13
+ .loading {
14
+ height: 100%;
15
+ display: flex;
16
+ align-items: center;
17
+ justify-content: center;
18
+ font-size: 2rem;
19
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
20
+ }
21
+ </style>
22
+ <link rel="stylesheet" href="https://esm.sh/graphiql/dist/style.css" />
23
+ <link rel="stylesheet" href="https://esm.sh/@graphiql/plugin-explorer/dist/style.css" />
24
+ <script type="importmap">
25
+ {
26
+ "imports": {
27
+ "react": "https://esm.sh/react@19.1.0",
28
+ "react/jsx-runtime": "https://esm.sh/react@19.1.0/jsx-runtime",
29
+ "react-dom": "https://esm.sh/react-dom@19.1.0",
30
+ "react-dom/client": "https://esm.sh/react-dom@19.1.0/client",
31
+ "@emotion/is-prop-valid": "data:text/javascript,",
32
+ "graphiql": "https://esm.sh/graphiql?standalone&external=react,react-dom,@graphiql/react,graphql",
33
+ "graphiql/": "https://esm.sh/graphiql/",
34
+ "@graphiql/plugin-explorer": "https://esm.sh/@graphiql/plugin-explorer?standalone&external=react,@graphiql/react,graphql",
35
+ "@graphiql/react": "https://esm.sh/@graphiql/react?standalone&external=react,react-dom,graphql,@emotion/is-prop-valid",
36
+ "@graphiql/toolkit": "https://esm.sh/@graphiql/toolkit?standalone&external=graphql",
37
+ "graphql": "https://esm.sh/graphql@16.11.0"
38
+ }
39
+ }
40
+ </script>
41
+ </head>
42
+ <body>
43
+ <div id="graphiql">
44
+ <div class="loading">Loading GraphiQL...</div>
45
+ </div>
46
+ <script>
47
+ // Offline/intranet notice: the imports below come from the esm.sh CDN;
48
+ // when it is unreachable the loading placeholder would hang forever.
49
+ // If GraphiQL has not rendered after 10s, say so and point at the raw
50
+ // endpoint (which never depended on the CDN).
51
+ window.setTimeout(function () {
52
+ var el = document.querySelector('#graphiql .loading');
53
+ if (el) {
54
+ el.textContent = 'GraphiQL failed to load — esm.sh CDN unreachable ' +
55
+ '(offline or restricted network?). The GraphQL API itself is fine: ' +
56
+ 'POST {graphql_url} directly.';
57
+ }
58
+ }, 10000);
59
+ </script>
60
+ <script type="module">
61
+ import React from 'react';
62
+ import ReactDOM from 'react-dom/client';
63
+ import { GraphiQL } from 'graphiql';
64
+ import { createGraphiQLFetcher } from '@graphiql/toolkit';
65
+ import { explorerPlugin } from '@graphiql/plugin-explorer';
66
+
67
+ const fetcher = createGraphiQLFetcher({ url: '{graphql_url}' });
68
+
69
+ function App() {
70
+ return React.createElement(GraphiQL, {
71
+ fetcher: fetcher,
72
+ plugins: [explorerPlugin()]
73
+ });
74
+ }
75
+
76
+ const container = document.getElementById('graphiql');
77
+ const root = ReactDOM.createRoot(container);
78
+ root.render(React.createElement(App));
79
+ </script>
80
+ </body>
81
+ </html>
82
+ """
@@ -0,0 +1,183 @@
1
+ """The non-MCP entry point: a GraphQL handler over a FastAPI app.
2
+
3
+ Constructing the handler scans the app and builds the schema eagerly, so
4
+ configuration problems surface at startup, not at first query.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Mapping, Sequence
10
+ from typing import Any
11
+
12
+ from graphql import GraphQLSchema, graphql, print_schema
13
+ from opentelemetry import trace
14
+
15
+ from fastapi_gql_mcp.depth_guard import depth_error
16
+ from fastapi_gql_mcp.invoker import InvocationContext, RouteInvoker
17
+ from fastapi_gql_mcp.scanner import RouteInfo, RouterScanner, SkipRecord
18
+ from fastapi_gql_mcp.schema_builder import GQLMCPConfigError, SchemaBuilder
19
+ from fastapi_gql_mcp.type_builder import TypeBuilder
20
+
21
+
22
+ class RouterGraphQLHandler:
23
+ """Exposes a FastAPI app as an executable GraphQL schema."""
24
+
25
+ def __init__(
26
+ self,
27
+ app: Any,
28
+ *,
29
+ include: Sequence[str] | None = None,
30
+ exclude: Sequence[str] | None = None,
31
+ allow_mutation: bool = False,
32
+ include_hidden: bool = False,
33
+ mutation_include: Sequence[str] | None = None,
34
+ passthrough_headers: Sequence[str] | None = None,
35
+ request_timeout: float | None = 30.0,
36
+ max_concurrency: int | None = 16,
37
+ max_depth: int | None = 10,
38
+ validation_rules: Sequence[Any] | None = None,
39
+ ) -> None:
40
+ if max_depth is not None and max_depth < 1:
41
+ raise ValueError("max_depth must be >= 1, or None to disable")
42
+ self._invoker = RouteInvoker(
43
+ app, timeout=request_timeout, max_concurrency=max_concurrency
44
+ )
45
+ self._max_depth = max_depth
46
+ self._validation_rules = tuple(validation_rules) if validation_rules else ()
47
+ # Whitelist of inbound header names untrusted callers may forward into
48
+ # route calls, lowercased at construction. None = the default
49
+ # ("authorization",): same-app bridges speak for the caller, so the
50
+ # caller's own credential travels by default. An explicitly empty
51
+ # sequence disables passthrough entirely.
52
+ if passthrough_headers is None:
53
+ self._passthrough_headers: tuple[str, ...] = ("authorization",)
54
+ else:
55
+ self._passthrough_headers = tuple(
56
+ h.strip().lower() for h in passthrough_headers if h.strip()
57
+ )
58
+ self._types = TypeBuilder()
59
+ routes, skips = RouterScanner(
60
+ app,
61
+ include=include,
62
+ exclude=exclude,
63
+ allow_mutation=allow_mutation,
64
+ include_hidden=include_hidden,
65
+ mutation_include=mutation_include,
66
+ ).scan(self._types)
67
+ self._builder = SchemaBuilder(routes, self._invoker, self._types)
68
+ self._schema = self._builder.build()
69
+ self._routes: list[RouteInfo] = routes
70
+ self._skips: list[SkipRecord] = skips
71
+
72
+ @property
73
+ def schema(self) -> GraphQLSchema:
74
+ return self._schema
75
+
76
+ @property
77
+ def routes(self) -> list[RouteInfo]:
78
+ return list(self._routes)
79
+
80
+ @property
81
+ def skips(self) -> list[SkipRecord]:
82
+ """Routes excluded from the schema, with reasons — the scanner's
83
+ report, so callers can assert nothing disappeared unexpectedly
84
+ (e.g. a new endpoint silently failing to map in CI)."""
85
+ return list(self._skips)
86
+
87
+ @property
88
+ def invoker(self) -> RouteInvoker:
89
+ return self._invoker
90
+
91
+ @property
92
+ def passthrough_headers(self) -> tuple[str, ...]:
93
+ """Lowercased header names callers may forward (default: authorization only)."""
94
+ return self._passthrough_headers
95
+
96
+ @property
97
+ def query_fields(self) -> dict[tuple[tuple[str, ...], str], Any]:
98
+ """(domain path, field name) -> GraphQLField index (the schema's leaves)."""
99
+ return self._builder.query_fields
100
+
101
+ @property
102
+ def mutation_fields(self) -> dict[tuple[tuple[str, ...], str], Any]:
103
+ return self._builder.mutation_fields
104
+
105
+ def get_sdl(self) -> str:
106
+ """Full schema in SDL form (the MCP ``get_schema`` payload)."""
107
+ return print_schema(self._schema)
108
+
109
+ async def execute(
110
+ self,
111
+ query: str,
112
+ *,
113
+ variables: dict[str, Any] | None = None,
114
+ operation_name: str | None = None,
115
+ headers: Mapping[str, str] | None = None,
116
+ ) -> dict[str, Any]:
117
+ """Execute a GraphQL query against the app's routes.
118
+
119
+ ``headers`` are TRUSTED per-call headers — the single credential
120
+ channel (there is no server-side provider). Callers sourcing them
121
+ from an untrusted origin (MCP request, /graphql endpoint) must filter
122
+ them through ``filter_passthrough_headers`` first.
123
+ """
124
+ if self._max_depth is not None:
125
+ error = depth_error(query, self._max_depth)
126
+ if error is not None:
127
+ return {"errors": [error.formatted]}
128
+ await self._invoker.start()
129
+ # The span that makes the middle layer visible: fastmcp's tool span
130
+ # covers the call, FastAPI's route spans cover execution — this one
131
+ # wraps the GraphQL orchestration between them (and the invoker
132
+ # injects its trace context FROM here, so route spans nest under
133
+ # it). No-op without an SDK installed.
134
+ with trace.get_tracer("fastapi_gql_mcp").start_as_current_span(
135
+ "graphql.execute"
136
+ ) as span:
137
+ span.set_attribute(
138
+ "graphql.operation_name", operation_name or "_anonymous"
139
+ )
140
+ result = await graphql(
141
+ self._schema,
142
+ query,
143
+ variable_values=variables or {},
144
+ operation_name=operation_name,
145
+ context_value=InvocationContext(headers=headers),
146
+ rules=self._validation_rules or None,
147
+ )
148
+ if result.errors:
149
+ span.set_attribute("graphql.error_count", len(result.errors))
150
+ payload: dict[str, Any] = {}
151
+ if result.data is not None:
152
+ payload["data"] = result.data
153
+ if result.errors:
154
+ payload["errors"] = [error.formatted for error in result.errors]
155
+ return payload
156
+
157
+ async def aclose(self) -> None:
158
+ """Release the invoker's HTTP client and app lifespan."""
159
+ await self._invoker.aclose()
160
+
161
+ def mount_graphql(
162
+ self,
163
+ app: Any,
164
+ *,
165
+ graphql_path: str = "/graphql",
166
+ graphiql_path: str = "/graphiql",
167
+ ) -> None:
168
+ """Serve a GraphiQL playground + GraphQL HTTP endpoint on a FastAPI app.
169
+
170
+ Mounting into the SAME app that fastapi-gql-mcp wraps disables the invoker's
171
+ lifespan management (the app's own server lifespan drives it once).
172
+ """
173
+ from fastapi_gql_mcp.http_api import create_graphql_router
174
+
175
+ if app is self._invoker.app:
176
+ self._invoker.disable_lifespan_management()
177
+ router = create_graphql_router(
178
+ self, graphql_path=graphql_path, graphiql_path=graphiql_path
179
+ )
180
+ app.include_router(router)
181
+
182
+
183
+ __all__ = ["RouterGraphQLHandler", "GQLMCPConfigError"]
@@ -0,0 +1,89 @@
1
+ """GraphQL-over-HTTP endpoints: a GraphiQL playground + a POST executor."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ from fastapi import APIRouter, Request
9
+ from fastapi.responses import HTMLResponse, JSONResponse
10
+
11
+ from fastapi_gql_mcp.graphiql import GRAPHIQL_HTML
12
+ from fastapi_gql_mcp.invoker import filter_passthrough_headers
13
+
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ def create_graphql_router(
18
+ handler: Any,
19
+ *,
20
+ graphql_path: str = "/graphql",
21
+ graphiql_path: str = "/graphiql",
22
+ ) -> APIRouter:
23
+ """Build an APIRouter serving GraphiQL and a GraphQL HTTP endpoint.
24
+
25
+ Args:
26
+ handler: A ``RouterGraphQLHandler``.
27
+ graphql_path: POST endpoint executing GraphQL documents. Body:
28
+ ``{"query": str, "variables"?: dict, "operationName"?: str}``.
29
+ graphiql_path: GET endpoint serving the playground, wired to
30
+ ``graphql_path``.
31
+
32
+ The endpoint shares the handler's ``passthrough_headers`` whitelist with
33
+ the MCP face: incoming credentials travel to the routes under the same
34
+ rules. Want browser sessions through GraphiQL? Configure
35
+ ``passthrough_headers=["authorization", "cookie"]``.
36
+ """
37
+ router = APIRouter()
38
+
39
+ @router.get(graphiql_path, include_in_schema=False)
40
+ async def graphiql() -> HTMLResponse:
41
+ html = GRAPHIQL_HTML.replace("{graphql_url}", graphql_path)
42
+ return HTMLResponse(html)
43
+
44
+ @router.post(graphql_path)
45
+ async def execute_graphql(request: Request) -> JSONResponse:
46
+ try:
47
+ body: dict[str, Any] = await request.json()
48
+ except Exception:
49
+ return JSONResponse(
50
+ {"errors": [{"message": "Request body must be JSON."}]}, status_code=400
51
+ )
52
+ query = body.get("query")
53
+ if not isinstance(query, str) or not query.strip():
54
+ return JSONResponse(
55
+ {"errors": [{"message": "Missing 'query' in request body."}]},
56
+ status_code=400,
57
+ )
58
+ variables = body.get("variables")
59
+ operation_name = body.get("operationName")
60
+ # The invoker re-issues a fresh ASGI request, so the inbound request's
61
+ # headers do NOT travel with it — extract and whitelist them here
62
+ # (same filter, same whitelist as the MCP face).
63
+ allowed = handler.passthrough_headers
64
+ headers = filter_passthrough_headers(request.headers, allowed) if allowed else None
65
+ result = await handler.execute(
66
+ query,
67
+ variables=variables if isinstance(variables, dict) else None,
68
+ operation_name=operation_name if isinstance(operation_name, str) else None,
69
+ headers=headers,
70
+ )
71
+ status = 200 if result.get("data") is not None else 400
72
+ return JSONResponse(result, status_code=status)
73
+
74
+ @router.get(graphql_path, include_in_schema=False)
75
+ async def graphql_usage() -> JSONResponse:
76
+ return JSONResponse(
77
+ {
78
+ "error": "GraphQL over HTTP is POST-only.",
79
+ "usage": {
80
+ "method": "POST",
81
+ "path": graphql_path,
82
+ "body": {"query": "...", "variables": {}, "operationName": None},
83
+ "playground": graphiql_path,
84
+ },
85
+ },
86
+ status_code=405,
87
+ )
88
+
89
+ return router