graphql-mcp 1.0.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.
- graphql_mcp-1.0.0/LICENSE +21 -0
- graphql_mcp-1.0.0/MANIFEST.in +2 -0
- graphql_mcp-1.0.0/PKG-INFO +43 -0
- graphql_mcp-1.0.0/README.md +1 -0
- graphql_mcp-1.0.0/VERSION +1 -0
- graphql_mcp-1.0.0/graphql_mcp/__init__.py +0 -0
- graphql_mcp-1.0.0/graphql_mcp/server.py +270 -0
- graphql_mcp-1.0.0/graphql_mcp.egg-info/PKG-INFO +43 -0
- graphql_mcp-1.0.0/graphql_mcp.egg-info/SOURCES.txt +15 -0
- graphql_mcp-1.0.0/graphql_mcp.egg-info/dependency_links.txt +1 -0
- graphql_mcp-1.0.0/graphql_mcp.egg-info/requires.txt +12 -0
- graphql_mcp-1.0.0/graphql_mcp.egg-info/top_level.txt +2 -0
- graphql_mcp-1.0.0/setup.cfg +15 -0
- graphql_mcp-1.0.0/setup.py +51 -0
- graphql_mcp-1.0.0/tests/__init__.py +8 -0
- graphql_mcp-1.0.0/tests/test_graphql_mcp.py +402 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Robert Parker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphql_mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A framework for building Python GraphQL MCP servers.
|
|
5
|
+
Home-page: https://gitlab.com/parob/graphql-mcp
|
|
6
|
+
Download-URL: https://gitlab.com/parob/graphql-mcp/-/archive/v1.0.0/graphql_mcp-v1.0.0.tar.gz
|
|
7
|
+
Author: Robert Parker
|
|
8
|
+
Author-email: rob@parob.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: GraphQL,GraphQL-API,GraphQLAPI,Server,MCP,Multi-Model-Protocol
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: graphql-core~=3.1
|
|
19
|
+
Requires-Dist: fastmcp~=2.9
|
|
20
|
+
Requires-Dist: graphql-api>=1.3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: graphql-api>=1.3.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest~=5.4; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov~=2.10; extra == "dev"
|
|
25
|
+
Requires-Dist: coverage~=5.2; extra == "dev"
|
|
26
|
+
Requires-Dist: faker~=4.1; extra == "dev"
|
|
27
|
+
Requires-Dist: fastmcp~=2.9; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio~=0.18; extra == "dev"
|
|
29
|
+
Dynamic: author
|
|
30
|
+
Dynamic: author-email
|
|
31
|
+
Dynamic: classifier
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: download-url
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: keywords
|
|
37
|
+
Dynamic: license
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
Dynamic: provides-extra
|
|
40
|
+
Dynamic: requires-dist
|
|
41
|
+
Dynamic: summary
|
|
42
|
+
|
|
43
|
+
# graphql-mcp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# graphql-mcp
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
1.0.0
|
|
File without changes
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import enum
|
|
2
|
+
import inspect
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
from datetime import date, datetime
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from fastmcp import FastMCP
|
|
11
|
+
|
|
12
|
+
from graphql import (
|
|
13
|
+
GraphQLArgument,
|
|
14
|
+
GraphQLEnumType,
|
|
15
|
+
GraphQLField,
|
|
16
|
+
GraphQLInputObjectType,
|
|
17
|
+
GraphQLList,
|
|
18
|
+
GraphQLNonNull,
|
|
19
|
+
GraphQLSchema,
|
|
20
|
+
GraphQLString,
|
|
21
|
+
GraphQLInt,
|
|
22
|
+
GraphQLFloat,
|
|
23
|
+
GraphQLBoolean,
|
|
24
|
+
GraphQLID,
|
|
25
|
+
get_named_type,
|
|
26
|
+
graphql_sync,
|
|
27
|
+
is_leaf_type,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
from graphql_api.types import (
|
|
32
|
+
GraphQLUUID,
|
|
33
|
+
GraphQLDateTime,
|
|
34
|
+
GraphQLDate,
|
|
35
|
+
GraphQLJSON,
|
|
36
|
+
GraphQLBytes,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
HAS_GRAPHQL_API = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
HAS_GRAPHQL_API = False
|
|
42
|
+
GraphQLUUID = object()
|
|
43
|
+
GraphQLDateTime = object()
|
|
44
|
+
GraphQLDate = object()
|
|
45
|
+
GraphQLJSON = object()
|
|
46
|
+
GraphQLBytes = object()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _map_graphql_type_to_python_type(graphql_type: Any) -> Any:
|
|
50
|
+
"""
|
|
51
|
+
Maps a GraphQL type to a Python type for function signatures.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(graphql_type, GraphQLNonNull):
|
|
54
|
+
return _map_graphql_type_to_python_type(graphql_type.of_type)
|
|
55
|
+
if isinstance(graphql_type, GraphQLList):
|
|
56
|
+
return list[_map_graphql_type_to_python_type(graphql_type.of_type)]
|
|
57
|
+
|
|
58
|
+
# Scalar types
|
|
59
|
+
if graphql_type is GraphQLString:
|
|
60
|
+
return str
|
|
61
|
+
if graphql_type is GraphQLInt:
|
|
62
|
+
return int
|
|
63
|
+
if graphql_type is GraphQLFloat:
|
|
64
|
+
return float
|
|
65
|
+
if graphql_type is GraphQLBoolean:
|
|
66
|
+
return bool
|
|
67
|
+
if graphql_type is GraphQLID:
|
|
68
|
+
return str
|
|
69
|
+
|
|
70
|
+
if HAS_GRAPHQL_API:
|
|
71
|
+
if graphql_type is GraphQLUUID:
|
|
72
|
+
return uuid.UUID
|
|
73
|
+
if graphql_type is GraphQLDateTime:
|
|
74
|
+
return datetime
|
|
75
|
+
if graphql_type is GraphQLDate:
|
|
76
|
+
return date
|
|
77
|
+
if graphql_type is GraphQLJSON:
|
|
78
|
+
return Any
|
|
79
|
+
if graphql_type is GraphQLBytes:
|
|
80
|
+
return bytes
|
|
81
|
+
|
|
82
|
+
if isinstance(graphql_type, GraphQLEnumType):
|
|
83
|
+
# Create a Python enum from the GraphQL enum
|
|
84
|
+
return enum.Enum(
|
|
85
|
+
graphql_type.name,
|
|
86
|
+
{k: v.value for k, v in graphql_type.values.items()},
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
if isinstance(graphql_type, GraphQLInputObjectType):
|
|
90
|
+
# This is complex. For now, we'll treat it as a dict.
|
|
91
|
+
# fastmcp can handle pydantic models or dataclasses.
|
|
92
|
+
# We might need to generate them on the fly.
|
|
93
|
+
return dict
|
|
94
|
+
|
|
95
|
+
return Any
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _to_snake_case(name: str) -> str:
|
|
99
|
+
"""Converts a camelCase string to snake_case."""
|
|
100
|
+
return re.sub(r"(?<!^)(?=[A-Z])", "_", name).lower()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _get_graphql_type_name(graphql_type: Any) -> str:
|
|
104
|
+
"""
|
|
105
|
+
Gets the name of a GraphQL type for use in a query string.
|
|
106
|
+
"""
|
|
107
|
+
if isinstance(graphql_type, GraphQLNonNull):
|
|
108
|
+
return f"{_get_graphql_type_name(graphql_type.of_type)}!"
|
|
109
|
+
if isinstance(graphql_type, GraphQLList):
|
|
110
|
+
return f"[{_get_graphql_type_name(graphql_type.of_type)}]"
|
|
111
|
+
return graphql_type.name
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _build_selection_set(graphql_type: Any, max_depth: int = 2, depth: int = 0) -> str:
|
|
115
|
+
"""
|
|
116
|
+
Builds a selection set for a GraphQL type.
|
|
117
|
+
Only includes scalar fields.
|
|
118
|
+
"""
|
|
119
|
+
if depth >= max_depth:
|
|
120
|
+
return ""
|
|
121
|
+
|
|
122
|
+
named_type = get_named_type(graphql_type)
|
|
123
|
+
if is_leaf_type(named_type):
|
|
124
|
+
return ""
|
|
125
|
+
|
|
126
|
+
selections = []
|
|
127
|
+
if hasattr(named_type, "fields"):
|
|
128
|
+
for field_name, field_def in named_type.fields.items():
|
|
129
|
+
field_named_type = get_named_type(field_def.type)
|
|
130
|
+
if is_leaf_type(field_named_type):
|
|
131
|
+
selections.append(field_name)
|
|
132
|
+
else:
|
|
133
|
+
nested_selection = _build_selection_set(
|
|
134
|
+
field_def.type, max_depth=max_depth, depth=depth + 1
|
|
135
|
+
)
|
|
136
|
+
if nested_selection:
|
|
137
|
+
selections.append(f"{field_name} {nested_selection}")
|
|
138
|
+
|
|
139
|
+
if not selections:
|
|
140
|
+
# If no leaf fields, maybe it's an object with no scalar fields.
|
|
141
|
+
# What to do here? Can't return an empty object.
|
|
142
|
+
# Maybe just return __typename as a default.
|
|
143
|
+
return "{ __typename }"
|
|
144
|
+
|
|
145
|
+
return f"{{ {', '.join(selections)} }}"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _add_tools_from_fields(
|
|
149
|
+
server: FastMCP,
|
|
150
|
+
schema: GraphQLSchema,
|
|
151
|
+
fields: dict[str, Any],
|
|
152
|
+
is_mutation: bool,
|
|
153
|
+
):
|
|
154
|
+
"""Internal helper to add tools from a dictionary of fields."""
|
|
155
|
+
for field_name, field in fields.items():
|
|
156
|
+
snake_case_name = _to_snake_case(field_name)
|
|
157
|
+
tool_func = _create_tool_function(
|
|
158
|
+
field_name, field, schema, is_mutation=is_mutation
|
|
159
|
+
)
|
|
160
|
+
tool_decorator = server.tool(name=snake_case_name)
|
|
161
|
+
tool_decorator(tool_func)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def add_query_tools_from_schema(server: FastMCP, schema: GraphQLSchema):
|
|
165
|
+
"""Adds tools to a FastMCP server from the query fields of a GraphQL schema."""
|
|
166
|
+
if schema.query_type:
|
|
167
|
+
_add_tools_from_fields(
|
|
168
|
+
server, schema, schema.query_type.fields, is_mutation=False
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
async def add_mutation_tools_from_schema(server: FastMCP, schema: GraphQLSchema):
|
|
173
|
+
"""Adds tools to a FastMCP server from the mutation fields of a GraphQL schema."""
|
|
174
|
+
if schema.mutation_type:
|
|
175
|
+
_add_tools_from_fields(
|
|
176
|
+
server, schema, schema.mutation_type.fields, is_mutation=True
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
async def from_graphql_schema(
|
|
181
|
+
schema: GraphQLSchema, server: FastMCP | None = None
|
|
182
|
+
) -> FastMCP:
|
|
183
|
+
"""
|
|
184
|
+
Populates a FastMCP server with tools generated from a GraphQLSchema.
|
|
185
|
+
|
|
186
|
+
If a server instance is not provided, a new one will be created.
|
|
187
|
+
Processes mutations first, then queries, so that queries will overwrite
|
|
188
|
+
any mutations with the same name.
|
|
189
|
+
|
|
190
|
+
:param schema: The GraphQLSchema to map.
|
|
191
|
+
:param server: An optional existing FastMCP server instance to add tools to.
|
|
192
|
+
:return: The populated FastMCP server instance.
|
|
193
|
+
"""
|
|
194
|
+
if server is None:
|
|
195
|
+
server = FastMCP(name=schema.description or "GraphQL Server")
|
|
196
|
+
|
|
197
|
+
# Process mutations first, so that queries can overwrite them if a name collision occurs.
|
|
198
|
+
await add_mutation_tools_from_schema(server, schema)
|
|
199
|
+
await add_query_tools_from_schema(server, schema)
|
|
200
|
+
|
|
201
|
+
return server
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _create_tool_function(
|
|
205
|
+
field_name: str,
|
|
206
|
+
field: GraphQLField,
|
|
207
|
+
schema: GraphQLSchema,
|
|
208
|
+
is_mutation: bool = False,
|
|
209
|
+
) -> Callable:
|
|
210
|
+
"""
|
|
211
|
+
Creates a function that can be decorated as a fastmcp tool.
|
|
212
|
+
"""
|
|
213
|
+
parameters = []
|
|
214
|
+
arg_defs = []
|
|
215
|
+
annotations = {}
|
|
216
|
+
for arg_name, arg_def in field.args.items():
|
|
217
|
+
arg_def: GraphQLArgument
|
|
218
|
+
python_type = _map_graphql_type_to_python_type(arg_def.type)
|
|
219
|
+
annotations[arg_name] = python_type
|
|
220
|
+
default = (
|
|
221
|
+
arg_def.default_value
|
|
222
|
+
if arg_def.default_value is not inspect.Parameter.empty
|
|
223
|
+
else inspect.Parameter.empty
|
|
224
|
+
)
|
|
225
|
+
kind = inspect.Parameter.POSITIONAL_OR_KEYWORD
|
|
226
|
+
parameters.append(
|
|
227
|
+
inspect.Parameter(arg_name, kind, default=default, annotation=python_type)
|
|
228
|
+
)
|
|
229
|
+
arg_defs.append(f"${arg_name}: {_get_graphql_type_name(arg_def.type)}")
|
|
230
|
+
|
|
231
|
+
def wrapper(**kwargs):
|
|
232
|
+
# Convert enums to their values for graphql_sync
|
|
233
|
+
processed_kwargs = {}
|
|
234
|
+
for k, v in kwargs.items():
|
|
235
|
+
if isinstance(v, enum.Enum):
|
|
236
|
+
processed_kwargs[k] = v.value
|
|
237
|
+
elif hasattr(v, "model_dump"): # Check for Pydantic model
|
|
238
|
+
processed_kwargs[k] = v.model_dump(mode="json")
|
|
239
|
+
elif isinstance(v, dict):
|
|
240
|
+
# graphql-api expects a JSON string for dict inputs
|
|
241
|
+
processed_kwargs[k] = json.dumps(v)
|
|
242
|
+
else:
|
|
243
|
+
processed_kwargs[k] = v
|
|
244
|
+
|
|
245
|
+
operation_type = "mutation" if is_mutation else "query"
|
|
246
|
+
arg_str = ", ".join(f"{name}: ${name}" for name in kwargs)
|
|
247
|
+
selection_set = _build_selection_set(field.type)
|
|
248
|
+
|
|
249
|
+
query_str = f"{operation_type} ({', '.join(arg_defs)}) {{ {field_name}({arg_str}) {selection_set} }}"
|
|
250
|
+
if not arg_defs:
|
|
251
|
+
query_str = f"{operation_type} {{ {field_name} {selection_set} }}"
|
|
252
|
+
|
|
253
|
+
# Execute the query
|
|
254
|
+
result = graphql_sync(schema, query_str, variable_values=processed_kwargs)
|
|
255
|
+
|
|
256
|
+
if result.errors:
|
|
257
|
+
# For simplicity, just raise the first error
|
|
258
|
+
raise result.errors[0]
|
|
259
|
+
|
|
260
|
+
if result.data:
|
|
261
|
+
return result.data.get(field_name)
|
|
262
|
+
|
|
263
|
+
return None
|
|
264
|
+
|
|
265
|
+
wrapper.__signature__ = inspect.Signature(parameters)
|
|
266
|
+
wrapper.__doc__ = field.description
|
|
267
|
+
wrapper.__name__ = _to_snake_case(field_name)
|
|
268
|
+
wrapper.__annotations__ = annotations
|
|
269
|
+
|
|
270
|
+
return wrapper
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: graphql_mcp
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A framework for building Python GraphQL MCP servers.
|
|
5
|
+
Home-page: https://gitlab.com/parob/graphql-mcp
|
|
6
|
+
Download-URL: https://gitlab.com/parob/graphql-mcp/-/archive/v1.0.0/graphql_mcp-v1.0.0.tar.gz
|
|
7
|
+
Author: Robert Parker
|
|
8
|
+
Author-email: rob@parob.com
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: GraphQL,GraphQL-API,GraphQLAPI,Server,MCP,Multi-Model-Protocol
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: graphql-core~=3.1
|
|
19
|
+
Requires-Dist: fastmcp~=2.9
|
|
20
|
+
Requires-Dist: graphql-api>=1.3.0
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: graphql-api>=1.3.0; extra == "dev"
|
|
23
|
+
Requires-Dist: pytest~=5.4; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-cov~=2.10; extra == "dev"
|
|
25
|
+
Requires-Dist: coverage~=5.2; extra == "dev"
|
|
26
|
+
Requires-Dist: faker~=4.1; extra == "dev"
|
|
27
|
+
Requires-Dist: fastmcp~=2.9; extra == "dev"
|
|
28
|
+
Requires-Dist: pytest-asyncio~=0.18; extra == "dev"
|
|
29
|
+
Dynamic: author
|
|
30
|
+
Dynamic: author-email
|
|
31
|
+
Dynamic: classifier
|
|
32
|
+
Dynamic: description
|
|
33
|
+
Dynamic: description-content-type
|
|
34
|
+
Dynamic: download-url
|
|
35
|
+
Dynamic: home-page
|
|
36
|
+
Dynamic: keywords
|
|
37
|
+
Dynamic: license
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
Dynamic: provides-extra
|
|
40
|
+
Dynamic: requires-dist
|
|
41
|
+
Dynamic: summary
|
|
42
|
+
|
|
43
|
+
# graphql-mcp
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
MANIFEST.in
|
|
3
|
+
README.md
|
|
4
|
+
VERSION
|
|
5
|
+
setup.cfg
|
|
6
|
+
setup.py
|
|
7
|
+
graphql_mcp/__init__.py
|
|
8
|
+
graphql_mcp/server.py
|
|
9
|
+
graphql_mcp.egg-info/PKG-INFO
|
|
10
|
+
graphql_mcp.egg-info/SOURCES.txt
|
|
11
|
+
graphql_mcp.egg-info/dependency_links.txt
|
|
12
|
+
graphql_mcp.egg-info/requires.txt
|
|
13
|
+
graphql_mcp.egg-info/top_level.txt
|
|
14
|
+
tests/__init__.py
|
|
15
|
+
tests/test_graphql_mcp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[metadata]
|
|
2
|
+
description_file = README.md
|
|
3
|
+
|
|
4
|
+
[tool:pytest]
|
|
5
|
+
filterwarnings =
|
|
6
|
+
ignore:PYTEST_DONT_REWRITE:DeprecationWarning
|
|
7
|
+
ignore:ast.Str is deprecated and will be removed in Python 3.14; use ast.Constant instead:DeprecationWarning:docstring_parser.attrdoc
|
|
8
|
+
|
|
9
|
+
[flake8]
|
|
10
|
+
ignore = E501, W503, E402
|
|
11
|
+
|
|
12
|
+
[egg_info]
|
|
13
|
+
tag_build =
|
|
14
|
+
tag_date = 0
|
|
15
|
+
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import io
|
|
2
|
+
|
|
3
|
+
from setuptools import setup, find_packages
|
|
4
|
+
|
|
5
|
+
with io.open("README.md", "rt", encoding="utf8") as readme_file:
|
|
6
|
+
readme = readme_file.read()
|
|
7
|
+
|
|
8
|
+
with io.open("VERSION") as version_file:
|
|
9
|
+
version = version_file.read().strip().lower()
|
|
10
|
+
if version.startswith("v"):
|
|
11
|
+
version = version[1:]
|
|
12
|
+
|
|
13
|
+
setup(
|
|
14
|
+
name="graphql_mcp",
|
|
15
|
+
version=version,
|
|
16
|
+
license="MIT",
|
|
17
|
+
packages=find_packages(),
|
|
18
|
+
include_package_data=True,
|
|
19
|
+
author="Robert Parker",
|
|
20
|
+
author_email="rob@parob.com",
|
|
21
|
+
url="https://gitlab.com/parob/graphql-mcp",
|
|
22
|
+
download_url=f"https://gitlab.com/parob/graphql-mcp/-/archive/v{version}"
|
|
23
|
+
f"/graphql_mcp-v{version}.tar.gz",
|
|
24
|
+
keywords=["GraphQL", "GraphQL-API", "GraphQLAPI", "Server", "MCP", "Multi-Model-Protocol"],
|
|
25
|
+
description="A framework for building Python GraphQL MCP servers.",
|
|
26
|
+
long_description=readme,
|
|
27
|
+
long_description_content_type="text/markdown",
|
|
28
|
+
install_requires=[
|
|
29
|
+
"graphql-core~=3.1",
|
|
30
|
+
"fastmcp~=2.9",
|
|
31
|
+
"graphql-api>=1.3.0"
|
|
32
|
+
],
|
|
33
|
+
extras_require={
|
|
34
|
+
"dev": [
|
|
35
|
+
"graphql-api>=1.3.0",
|
|
36
|
+
"pytest~=5.4",
|
|
37
|
+
"pytest-cov~=2.10",
|
|
38
|
+
"coverage~=5.2",
|
|
39
|
+
"faker~=4.1",
|
|
40
|
+
"fastmcp~=2.9",
|
|
41
|
+
"pytest-asyncio~=0.18",
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
classifiers=[
|
|
45
|
+
"Development Status :: 3 - Alpha",
|
|
46
|
+
"Intended Audience :: Developers",
|
|
47
|
+
"Programming Language :: Python :: 3",
|
|
48
|
+
"License :: OSI Approved :: MIT License",
|
|
49
|
+
"Operating System :: OS Independent",
|
|
50
|
+
],
|
|
51
|
+
)
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# flake8: noqa
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from graphql_api.api import GraphQLAPI
|
|
5
|
+
from graphql_api.decorators import field, type
|
|
6
|
+
from graphql_api.error import GraphQLError
|
|
7
|
+
from graphql_api.executor import GraphQLExecutor
|
|
8
|
+
from graphql_api.reduce import GraphQLFilter, TagFilter
|
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import pytest
|
|
3
|
+
import enum
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
from graphql_api import GraphQLAPI
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
from fastmcp.client import Client
|
|
9
|
+
from mcp.types import TextContent
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from graphql_mcp.server import from_graphql_schema
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
async def test_from_graphql_schema():
|
|
17
|
+
api = GraphQLAPI()
|
|
18
|
+
|
|
19
|
+
@api.type(is_root_type=True)
|
|
20
|
+
class Root:
|
|
21
|
+
@api.field
|
|
22
|
+
def hello(self, name: str) -> str:
|
|
23
|
+
"""Returns a greeting."""
|
|
24
|
+
return f"Hello, {name}"
|
|
25
|
+
|
|
26
|
+
@api.field(mutable=True)
|
|
27
|
+
def add(self, a: int, b: int) -> int:
|
|
28
|
+
"""Adds two numbers."""
|
|
29
|
+
return a + b
|
|
30
|
+
|
|
31
|
+
schema, _ = api.build_schema()
|
|
32
|
+
|
|
33
|
+
mcp_server = await from_graphql_schema(schema)
|
|
34
|
+
|
|
35
|
+
async with Client(mcp_server) as client:
|
|
36
|
+
# Test query
|
|
37
|
+
result = await client.call_tool("hello", {"name": "World"})
|
|
38
|
+
assert cast(TextContent, result[0]).text == "Hello, World"
|
|
39
|
+
|
|
40
|
+
# Test mutation
|
|
41
|
+
result = await client.call_tool("add", {"a": 5, "b": 3})
|
|
42
|
+
assert cast(TextContent, result[0]).text == "8"
|
|
43
|
+
|
|
44
|
+
# Test tool listing
|
|
45
|
+
tools = await client.list_tools()
|
|
46
|
+
tool_names = {t.name for t in tools}
|
|
47
|
+
assert "hello" in tool_names
|
|
48
|
+
assert "add" in tool_names
|
|
49
|
+
|
|
50
|
+
hello_tool = next(t for t in tools if t.name == "hello")
|
|
51
|
+
assert hello_tool.description == "Returns a greeting."
|
|
52
|
+
|
|
53
|
+
add_tool = next(t for t in tools if t.name == "add")
|
|
54
|
+
assert add_tool.description == "Adds two numbers."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@pytest.mark.asyncio
|
|
58
|
+
async def test_from_graphql_schema_nested():
|
|
59
|
+
"""
|
|
60
|
+
Tests the schema mapping with a nested object type.
|
|
61
|
+
"""
|
|
62
|
+
api = GraphQLAPI()
|
|
63
|
+
|
|
64
|
+
@api.type
|
|
65
|
+
class Book:
|
|
66
|
+
@api.field
|
|
67
|
+
def title(self) -> str:
|
|
68
|
+
return "The Hitchhiker's Guide to the Galaxy"
|
|
69
|
+
|
|
70
|
+
@api.type
|
|
71
|
+
class Author:
|
|
72
|
+
@api.field
|
|
73
|
+
def name(self) -> str:
|
|
74
|
+
return "Douglas Adams"
|
|
75
|
+
|
|
76
|
+
@api.field
|
|
77
|
+
def book(self) -> Book:
|
|
78
|
+
return Book()
|
|
79
|
+
|
|
80
|
+
@api.type(is_root_type=True)
|
|
81
|
+
class Root:
|
|
82
|
+
@api.field
|
|
83
|
+
def author(self) -> Author:
|
|
84
|
+
return Author()
|
|
85
|
+
|
|
86
|
+
schema, _ = api.build_schema()
|
|
87
|
+
|
|
88
|
+
mcp_server = await from_graphql_schema(schema)
|
|
89
|
+
|
|
90
|
+
async with Client(mcp_server) as client:
|
|
91
|
+
result = await client.call_tool("author", {})
|
|
92
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
93
|
+
assert data["name"] == "Douglas Adams"
|
|
94
|
+
assert data["book"]["title"] == "The Hitchhiker's Guide to the Galaxy"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@pytest.mark.asyncio
|
|
98
|
+
async def test_from_graphql_schema_advanced():
|
|
99
|
+
"""
|
|
100
|
+
Tests more advanced schema features like enums, lists, and mutations on data.
|
|
101
|
+
"""
|
|
102
|
+
api = GraphQLAPI()
|
|
103
|
+
|
|
104
|
+
class Status(enum.Enum):
|
|
105
|
+
PENDING = "PENDING"
|
|
106
|
+
COMPLETED = "COMPLETED"
|
|
107
|
+
|
|
108
|
+
# In-memory "database"
|
|
109
|
+
items_db = {
|
|
110
|
+
1: {"id": 1, "name": "Task 1", "completed": False, "status": Status.PENDING},
|
|
111
|
+
2: {"id": 2, "name": "Task 2", "completed": True, "status": Status.COMPLETED},
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
@api.type
|
|
115
|
+
class Item:
|
|
116
|
+
def __init__(self, **data):
|
|
117
|
+
self._data = data
|
|
118
|
+
|
|
119
|
+
@api.field
|
|
120
|
+
def id(self) -> int:
|
|
121
|
+
return self._data["id"]
|
|
122
|
+
|
|
123
|
+
@api.field
|
|
124
|
+
def name(self) -> str:
|
|
125
|
+
return self._data["name"]
|
|
126
|
+
|
|
127
|
+
@api.field
|
|
128
|
+
def completed(self) -> bool:
|
|
129
|
+
return self._data["completed"]
|
|
130
|
+
|
|
131
|
+
@api.field
|
|
132
|
+
def status(self) -> Status:
|
|
133
|
+
return self._data["status"]
|
|
134
|
+
|
|
135
|
+
@api.field(mutable=True)
|
|
136
|
+
def rename(self, new_name: str) -> 'Item':
|
|
137
|
+
"""Updates the status of an item."""
|
|
138
|
+
self._data["name"] = new_name
|
|
139
|
+
return self
|
|
140
|
+
|
|
141
|
+
@api.type(is_root_type=True)
|
|
142
|
+
class Root:
|
|
143
|
+
@api.field
|
|
144
|
+
def items(self) -> list[Item]:
|
|
145
|
+
"""Returns all items."""
|
|
146
|
+
return [Item(**item_data) for item_data in items_db.values()]
|
|
147
|
+
|
|
148
|
+
@api.field
|
|
149
|
+
def item(self, id: int) -> Item | None:
|
|
150
|
+
"""Returns a single item by ID."""
|
|
151
|
+
if id in items_db:
|
|
152
|
+
return Item(**items_db[id])
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
@api.field
|
|
156
|
+
def filter_items(
|
|
157
|
+
self, completed: bool, status: str | None = None
|
|
158
|
+
) -> list[Item]:
|
|
159
|
+
"""Filters items by completion status and optionally by enum status."""
|
|
160
|
+
filtered_data = [
|
|
161
|
+
i for i in items_db.values() if i["completed"] == completed
|
|
162
|
+
]
|
|
163
|
+
if status:
|
|
164
|
+
filtered_data = [
|
|
165
|
+
i for i in filtered_data if i["status"].value == status
|
|
166
|
+
]
|
|
167
|
+
return [Item(**i) for i in filtered_data]
|
|
168
|
+
|
|
169
|
+
@api.field(mutable=True)
|
|
170
|
+
def update_item_status(self, id: int, status: str) -> Item:
|
|
171
|
+
"""Updates the status of an item."""
|
|
172
|
+
if id not in items_db:
|
|
173
|
+
raise ValueError(f"Item with ID {id} not found.")
|
|
174
|
+
items_db[id]["status"] = Status(status)
|
|
175
|
+
return Item(**items_db[id])
|
|
176
|
+
|
|
177
|
+
schema, _ = api.build_schema()
|
|
178
|
+
mcp_server = await from_graphql_schema(schema)
|
|
179
|
+
|
|
180
|
+
async with Client(mcp_server) as client:
|
|
181
|
+
# 1. Test list return
|
|
182
|
+
result = await client.call_tool("items", {})
|
|
183
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
184
|
+
assert len(data) == 2
|
|
185
|
+
assert data[0]["name"] == "Task 1"
|
|
186
|
+
|
|
187
|
+
# 2. Test query with arguments
|
|
188
|
+
result = await client.call_tool("item", {"id": 1})
|
|
189
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
190
|
+
assert data["name"] == "Task 1"
|
|
191
|
+
assert data["status"] == "PENDING"
|
|
192
|
+
|
|
193
|
+
# 3. Test mutation
|
|
194
|
+
result = await client.call_tool("update_item_status", {"id": 1, "status": "COMPLETED"})
|
|
195
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
196
|
+
assert data["status"] == "COMPLETED"
|
|
197
|
+
|
|
198
|
+
# 4. Test enum argument
|
|
199
|
+
result = await client.call_tool("filter_items", {"completed": True, "status": "COMPLETED"})
|
|
200
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
201
|
+
if isinstance(data, dict):
|
|
202
|
+
data = [data]
|
|
203
|
+
assert len(data) == 1
|
|
204
|
+
assert data[0]["name"] == "Task 2"
|
|
205
|
+
|
|
206
|
+
# 5. Verify that mutations on nested objects are NOT exposed as top-level tools.
|
|
207
|
+
# The `graphql-api` library only creates top-level mutations from methods
|
|
208
|
+
# on the class marked `is_root_type=True`. The `rename` method on the
|
|
209
|
+
# `Item` type is therefore not mapped to a top-level mutation.
|
|
210
|
+
all_tools = await client.list_tools()
|
|
211
|
+
assert "rename" not in [tool.name for tool in all_tools]
|
|
212
|
+
assert "rename_item" not in [tool.name for tool in all_tools]
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
@pytest.mark.asyncio
|
|
216
|
+
async def test_from_graphql_schema_with_existing_server():
|
|
217
|
+
"""
|
|
218
|
+
Tests that the schema mapping can be applied to an existing FastMCP server.
|
|
219
|
+
"""
|
|
220
|
+
api = GraphQLAPI()
|
|
221
|
+
|
|
222
|
+
@api.type(is_root_type=True)
|
|
223
|
+
class Root:
|
|
224
|
+
@api.field
|
|
225
|
+
def new_tool(self) -> str:
|
|
226
|
+
return "new"
|
|
227
|
+
|
|
228
|
+
schema, _ = api.build_schema()
|
|
229
|
+
|
|
230
|
+
# 1. Create a server with a pre-existing tool
|
|
231
|
+
mcp_server = FastMCP()
|
|
232
|
+
|
|
233
|
+
@mcp_server.tool
|
|
234
|
+
def existing_tool() -> str:
|
|
235
|
+
"""An existing tool."""
|
|
236
|
+
return "existing"
|
|
237
|
+
|
|
238
|
+
# 2. Populate the server from the schema
|
|
239
|
+
await from_graphql_schema(schema, server=mcp_server)
|
|
240
|
+
|
|
241
|
+
# 3. Verify both the old and new tools exist
|
|
242
|
+
async with Client(mcp_server) as client:
|
|
243
|
+
all_tools = await client.list_tools()
|
|
244
|
+
tool_names = [tool.name for tool in all_tools]
|
|
245
|
+
assert "existing_tool" in tool_names
|
|
246
|
+
assert "new_tool" in tool_names
|
|
247
|
+
|
|
248
|
+
# 4. Verify both tools are callable
|
|
249
|
+
result_existing = await client.call_tool("existing_tool", {})
|
|
250
|
+
assert cast(TextContent, result_existing[0]).text == "existing"
|
|
251
|
+
|
|
252
|
+
result_new = await client.call_tool("new_tool", {})
|
|
253
|
+
assert cast(TextContent, result_new[0]).text == "new"
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@pytest.mark.asyncio
|
|
257
|
+
async def test_from_graphql_schema_core_only():
|
|
258
|
+
"""
|
|
259
|
+
Tests that the schema mapping works with a schema built using only graphql-core.
|
|
260
|
+
"""
|
|
261
|
+
from graphql import (
|
|
262
|
+
GraphQLSchema,
|
|
263
|
+
GraphQLObjectType,
|
|
264
|
+
GraphQLField,
|
|
265
|
+
GraphQLString,
|
|
266
|
+
GraphQLArgument,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
def resolve_hello(root, info, name="world"):
|
|
270
|
+
return f"Hello, {name}"
|
|
271
|
+
|
|
272
|
+
query_type = GraphQLObjectType(
|
|
273
|
+
name="Query",
|
|
274
|
+
fields={
|
|
275
|
+
"hello": GraphQLField(
|
|
276
|
+
GraphQLString,
|
|
277
|
+
args={"name": GraphQLArgument(GraphQLString, default_value="world")},
|
|
278
|
+
resolve=resolve_hello,
|
|
279
|
+
)
|
|
280
|
+
},
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
schema = GraphQLSchema(query=query_type)
|
|
284
|
+
|
|
285
|
+
mcp_server = await from_graphql_schema(schema)
|
|
286
|
+
|
|
287
|
+
async with Client(mcp_server) as client:
|
|
288
|
+
# Test query
|
|
289
|
+
result = await client.call_tool("hello", {"name": "core"})
|
|
290
|
+
assert cast(TextContent, result[0]).text == "Hello, core"
|
|
291
|
+
|
|
292
|
+
tools = await client.list_tools()
|
|
293
|
+
assert len(tools) == 1
|
|
294
|
+
assert tools[0].name == "hello"
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
@pytest.mark.asyncio
|
|
298
|
+
async def test_error_handling():
|
|
299
|
+
"""Tests that GraphQL errors are raised as exceptions."""
|
|
300
|
+
from graphql import (
|
|
301
|
+
GraphQLSchema,
|
|
302
|
+
GraphQLObjectType,
|
|
303
|
+
GraphQLField,
|
|
304
|
+
GraphQLString,
|
|
305
|
+
)
|
|
306
|
+
from fastmcp.exceptions import ToolError
|
|
307
|
+
|
|
308
|
+
def resolve_error(root, info):
|
|
309
|
+
raise ValueError("This is a test error")
|
|
310
|
+
|
|
311
|
+
query_type = GraphQLObjectType(
|
|
312
|
+
name="Query",
|
|
313
|
+
fields={
|
|
314
|
+
"error_field": GraphQLField(
|
|
315
|
+
GraphQLString,
|
|
316
|
+
resolve=resolve_error,
|
|
317
|
+
)
|
|
318
|
+
},
|
|
319
|
+
)
|
|
320
|
+
schema = GraphQLSchema(query=query_type)
|
|
321
|
+
mcp_server = await from_graphql_schema(schema)
|
|
322
|
+
|
|
323
|
+
async with Client(mcp_server) as client:
|
|
324
|
+
with pytest.raises(ToolError, match="This is a test error"):
|
|
325
|
+
await client.call_tool("error_field", {})
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@pytest.mark.asyncio
|
|
329
|
+
async def test_from_graphql_schema_with_pydantic_input():
|
|
330
|
+
"""
|
|
331
|
+
Tests that a mutation with a pydantic model as input is correctly handled.
|
|
332
|
+
"""
|
|
333
|
+
api = GraphQLAPI()
|
|
334
|
+
|
|
335
|
+
class CreateItemInput(BaseModel):
|
|
336
|
+
name: str
|
|
337
|
+
price: float
|
|
338
|
+
|
|
339
|
+
@api.type
|
|
340
|
+
class Item:
|
|
341
|
+
@api.field
|
|
342
|
+
def name(self) -> str:
|
|
343
|
+
return "Test Item"
|
|
344
|
+
|
|
345
|
+
@api.field
|
|
346
|
+
def price(self) -> float:
|
|
347
|
+
return 12.34
|
|
348
|
+
|
|
349
|
+
@api.type(is_root_type=True)
|
|
350
|
+
class Root:
|
|
351
|
+
@api.field(mutable=True)
|
|
352
|
+
def create_item(self, input: dict) -> Item:
|
|
353
|
+
"""Creates an item."""
|
|
354
|
+
# In a real scenario, you'd use the input to create the item.
|
|
355
|
+
# Here we just return a dummy item to verify the tool call.
|
|
356
|
+
if isinstance(input, str):
|
|
357
|
+
input = json.loads(input)
|
|
358
|
+
assert input["name"] == "My Pydantic Item"
|
|
359
|
+
assert input["price"] == 99.99
|
|
360
|
+
return Item()
|
|
361
|
+
|
|
362
|
+
schema, _ = api.build_schema()
|
|
363
|
+
|
|
364
|
+
mcp_server = await from_graphql_schema(schema)
|
|
365
|
+
|
|
366
|
+
async with Client(mcp_server) as client:
|
|
367
|
+
input_data = CreateItemInput(name="My Pydantic Item", price=99.99)
|
|
368
|
+
result = await client.call_tool("create_item", {"input": input_data})
|
|
369
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
370
|
+
assert data["name"] == "Test Item"
|
|
371
|
+
assert data["price"] == 12.34
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
@pytest.mark.asyncio
|
|
375
|
+
async def test_from_graphql_schema_with_pydantic_output():
|
|
376
|
+
"""
|
|
377
|
+
Tests that a query that returns a pydantic model is correctly handled.
|
|
378
|
+
"""
|
|
379
|
+
api = GraphQLAPI()
|
|
380
|
+
|
|
381
|
+
class ItemOutput(BaseModel):
|
|
382
|
+
name: str
|
|
383
|
+
price: float
|
|
384
|
+
is_offer: bool = False
|
|
385
|
+
|
|
386
|
+
@api.type(is_root_type=True)
|
|
387
|
+
class Root:
|
|
388
|
+
@api.field
|
|
389
|
+
def get_item(self) -> ItemOutput:
|
|
390
|
+
"""Gets an item."""
|
|
391
|
+
return ItemOutput(name="A Pydantic Item", price=42.0, is_offer=True)
|
|
392
|
+
|
|
393
|
+
schema, _ = api.build_schema()
|
|
394
|
+
|
|
395
|
+
mcp_server = await from_graphql_schema(schema)
|
|
396
|
+
|
|
397
|
+
async with Client(mcp_server) as client:
|
|
398
|
+
result = await client.call_tool("get_item", {})
|
|
399
|
+
data = json.loads(cast(TextContent, result[0]).text)
|
|
400
|
+
assert data["name"] == "A Pydantic Item"
|
|
401
|
+
assert data["price"] == 42.0
|
|
402
|
+
assert data["isOffer"] is True
|