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.
- fastapi_gql_mcp/__init__.py +21 -0
- fastapi_gql_mcp/depth_guard.py +94 -0
- fastapi_gql_mcp/domains.py +146 -0
- fastapi_gql_mcp/graphiql.py +82 -0
- fastapi_gql_mcp/handler.py +183 -0
- fastapi_gql_mcp/http_api.py +89 -0
- fastapi_gql_mcp/invoker.py +336 -0
- fastapi_gql_mcp/mcp/__init__.py +0 -0
- fastapi_gql_mcp/mcp/errors.py +38 -0
- fastapi_gql_mcp/mcp/progressive_tools.py +274 -0
- fastapi_gql_mcp/mcp/server.py +274 -0
- fastapi_gql_mcp/mcp/tools.py +178 -0
- fastapi_gql_mcp/naming.py +35 -0
- fastapi_gql_mcp/scalars.py +110 -0
- fastapi_gql_mcp/scanner.py +395 -0
- fastapi_gql_mcp/schema_builder.py +290 -0
- fastapi_gql_mcp/type_builder.py +487 -0
- fastapi_gql_mcp-0.4.0.dist-info/METADATA +327 -0
- fastapi_gql_mcp-0.4.0.dist-info/RECORD +20 -0
- fastapi_gql_mcp-0.4.0.dist-info/WHEEL +4 -0
|
@@ -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
|