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.
@@ -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,2 @@
1
+ include README.md
2
+ include VERSION
@@ -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,12 @@
1
+ graphql-core~=3.1
2
+ fastmcp~=2.9
3
+ graphql-api>=1.3.0
4
+
5
+ [dev]
6
+ graphql-api>=1.3.0
7
+ pytest~=5.4
8
+ pytest-cov~=2.10
9
+ coverage~=5.2
10
+ faker~=4.1
11
+ fastmcp~=2.9
12
+ pytest-asyncio~=0.18
@@ -0,0 +1,2 @@
1
+ graphql_mcp
2
+ tests
@@ -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