utcp-gql 1.0.2__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,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: utcp-gql
3
+ Version: 1.0.2
4
+ Summary: UTCP communication protocol plugin for GraphQL. (Work in progress)
5
+ Author: UTCP Contributors
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://utcp.io
8
+ Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
9
+ Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: gql>=3.0
18
+ Requires-Dist: utcp>=1.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: build; extra == "dev"
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: coverage; extra == "dev"
25
+ Requires-Dist: twine; extra == "dev"
26
+
27
+
28
+ # UTCP GraphQL Communication Protocol Plugin
29
+
30
+ This plugin integrates GraphQL as a UTCP 1.0 communication protocol and call template. It supports discovery via schema introspection, authenticated calls, and header handling.
31
+
32
+ ## Getting Started
33
+
34
+ ### Installation
35
+
36
+ ```bash
37
+ pip install gql
38
+ ```
39
+
40
+ ### Registration
41
+
42
+ ```python
43
+ import utcp_gql
44
+ utcp_gql.register()
45
+ ```
46
+
47
+ ## How To Use
48
+
49
+ - Ensure the plugin is imported and registered: `import utcp_gql; utcp_gql.register()`.
50
+ - Add a manual in your client config:
51
+ ```json
52
+ {
53
+ "name": "my_graph",
54
+ "call_template_type": "graphql",
55
+ "url": "https://your.graphql/endpoint",
56
+ "operation_type": "query",
57
+ "headers": { "x-client": "utcp" },
58
+ "header_fields": ["x-session-id"]
59
+ }
60
+ ```
61
+ - Call a tool:
62
+ ```python
63
+ await client.call_tool("my_graph.someQuery", {"id": "123", "x-session-id": "abc"})
64
+ ```
65
+
66
+ ## Notes
67
+
68
+ - Tool names are prefixed by the manual name (e.g., `my_graph.someQuery`).
69
+ - Headers merge static `headers` plus whitelisted dynamic fields from `header_fields`.
70
+ - Supported auth: API key, Basic auth, OAuth2 (client-credentials).
71
+ - Security: only `https://` or `http://localhost`/`http://127.0.0.1` endpoints.
72
+
73
+ For UTCP core docs, see https://github.com/universal-tool-calling-protocol/python-utcp.
@@ -0,0 +1,47 @@
1
+
2
+ # UTCP GraphQL Communication Protocol Plugin
3
+
4
+ This plugin integrates GraphQL as a UTCP 1.0 communication protocol and call template. It supports discovery via schema introspection, authenticated calls, and header handling.
5
+
6
+ ## Getting Started
7
+
8
+ ### Installation
9
+
10
+ ```bash
11
+ pip install gql
12
+ ```
13
+
14
+ ### Registration
15
+
16
+ ```python
17
+ import utcp_gql
18
+ utcp_gql.register()
19
+ ```
20
+
21
+ ## How To Use
22
+
23
+ - Ensure the plugin is imported and registered: `import utcp_gql; utcp_gql.register()`.
24
+ - Add a manual in your client config:
25
+ ```json
26
+ {
27
+ "name": "my_graph",
28
+ "call_template_type": "graphql",
29
+ "url": "https://your.graphql/endpoint",
30
+ "operation_type": "query",
31
+ "headers": { "x-client": "utcp" },
32
+ "header_fields": ["x-session-id"]
33
+ }
34
+ ```
35
+ - Call a tool:
36
+ ```python
37
+ await client.call_tool("my_graph.someQuery", {"id": "123", "x-session-id": "abc"})
38
+ ```
39
+
40
+ ## Notes
41
+
42
+ - Tool names are prefixed by the manual name (e.g., `my_graph.someQuery`).
43
+ - Headers merge static `headers` plus whitelisted dynamic fields from `header_fields`.
44
+ - Supported auth: API key, Basic auth, OAuth2 (client-credentials).
45
+ - Security: only `https://` or `http://localhost`/`http://127.0.0.1` endpoints.
46
+
47
+ For UTCP core docs, see https://github.com/universal-tool-calling-protocol/python-utcp.
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61.0"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "utcp-gql"
7
+ version = "1.0.2"
8
+ authors = [
9
+ { name = "UTCP Contributors" },
10
+ ]
11
+ description = "UTCP communication protocol plugin for GraphQL. (Work in progress)"
12
+ readme = "README.md"
13
+ requires-python = ">=3.10"
14
+ dependencies = [
15
+ "pydantic>=2.0",
16
+ "gql>=3.0",
17
+ "utcp>=1.0"
18
+ ]
19
+ classifiers = [
20
+ "Development Status :: 4 - Beta",
21
+ "Intended Audience :: Developers",
22
+ "Programming Language :: Python :: 3",
23
+ "Operating System :: OS Independent",
24
+ ]
25
+ license = "MPL-2.0"
26
+
27
+ [project.optional-dependencies]
28
+ dev = [
29
+ "build",
30
+ "pytest",
31
+ "pytest-asyncio",
32
+ "pytest-cov",
33
+ "coverage",
34
+ "twine",
35
+ ]
36
+
37
+ [project.urls]
38
+ Homepage = "https://utcp.io"
39
+ Source = "https://github.com/universal-tool-calling-protocol/python-utcp"
40
+ Issues = "https://github.com/universal-tool-calling-protocol/python-utcp/issues"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,9 @@
1
+ from utcp.plugins.discovery import register_communication_protocol, register_call_template
2
+
3
+ from .gql_communication_protocol import GraphQLCommunicationProtocol
4
+ from .gql_call_template import GraphQLCallTemplate, GraphQLCallTemplateSerializer
5
+
6
+
7
+ def register():
8
+ register_communication_protocol("graphql", GraphQLCommunicationProtocol())
9
+ register_call_template("graphql", GraphQLCallTemplateSerializer())
@@ -0,0 +1,93 @@
1
+ from utcp.data.call_template import CallTemplate
2
+ from utcp.data.auth import Auth, AuthSerializer
3
+ from utcp.interfaces.serializer import Serializer
4
+ from utcp.exceptions import UtcpSerializerValidationError
5
+ import traceback
6
+ from typing import Dict, List, Optional, Literal
7
+ from pydantic import Field, field_serializer, field_validator
8
+
9
+ class GraphQLCallTemplate(CallTemplate):
10
+ """Provider configuration for GraphQL-based tools.
11
+
12
+ Enables communication with GraphQL endpoints supporting queries, mutations,
13
+ and subscriptions. Provides flexible query execution with custom headers
14
+ and authentication.
15
+
16
+ For maximum flexibility, use the `query` field to provide a complete GraphQL
17
+ query string with proper selection sets and variable types. This allows agents
18
+ to call any existing GraphQL endpoint without limitations.
19
+
20
+ Attributes:
21
+ call_template_type: Always "graphql" for GraphQL providers.
22
+ url: The GraphQL endpoint URL.
23
+ operation_type: The type of GraphQL operation (query, mutation, subscription).
24
+ operation_name: Optional name for the GraphQL operation.
25
+ auth: Optional authentication configuration.
26
+ headers: Optional static headers to include in requests.
27
+ header_fields: List of tool argument names to map to HTTP request headers.
28
+ query: Custom GraphQL query string with full control over selection sets
29
+ and variable types. Example: 'query GetUser($id: ID!) { user(id: $id) { id name } }'
30
+ variable_types: Map of variable names to GraphQL types for auto-generated queries.
31
+ Example: {'id': 'ID!', 'limit': 'Int'}. Defaults to 'String' if not specified.
32
+
33
+ Example:
34
+ # Full flexibility with custom query
35
+ template = GraphQLCallTemplate(
36
+ url="https://api.example.com/graphql",
37
+ query="query GetUser($id: ID!) { user(id: $id) { id name email } }",
38
+ )
39
+
40
+ # Auto-generation with proper types
41
+ template = GraphQLCallTemplate(
42
+ url="https://api.example.com/graphql",
43
+ variable_types={"limit": "Int", "active": "Boolean"},
44
+ )
45
+ """
46
+
47
+ call_template_type: Literal["graphql"] = "graphql"
48
+ url: str
49
+ operation_type: Literal["query", "mutation", "subscription"] = "query"
50
+ operation_name: Optional[str] = None
51
+ auth: Optional[Auth] = None
52
+ headers: Optional[Dict[str, str]] = None
53
+ header_fields: Optional[List[str]] = Field(default=None, description="List of input fields to be sent as request headers for the initial connection.")
54
+ query: Optional[str] = Field(
55
+ default=None,
56
+ description="Custom GraphQL query/mutation string. Use $varName syntax for variables. "
57
+ "If provided, this takes precedence over auto-generation. "
58
+ "Example: 'query GetUser($id: ID!) { user(id: $id) { id name email } }'"
59
+ )
60
+ variable_types: Optional[Dict[str, str]] = Field(
61
+ default=None,
62
+ description="Map of variable names to GraphQL types for auto-generated queries. "
63
+ "Example: {'id': 'ID!', 'limit': 'Int', 'active': 'Boolean'}. "
64
+ "Defaults to 'String' if not specified."
65
+ )
66
+
67
+ @field_serializer("auth")
68
+ def serialize_auth(self, auth: Optional[Auth]):
69
+ if auth is None:
70
+ return None
71
+ return AuthSerializer().to_dict(auth)
72
+
73
+ @field_validator("auth", mode="before")
74
+ @classmethod
75
+ def validate_auth(cls, v: Optional[Auth | dict]):
76
+ if v is None:
77
+ return None
78
+ if isinstance(v, Auth):
79
+ return v
80
+ return AuthSerializer().validate_dict(v)
81
+
82
+
83
+ class GraphQLCallTemplateSerializer(Serializer[GraphQLCallTemplate]):
84
+ def to_dict(self, obj: GraphQLCallTemplate) -> dict:
85
+ return obj.model_dump()
86
+
87
+ def validate_dict(self, data: dict) -> GraphQLCallTemplate:
88
+ try:
89
+ return GraphQLCallTemplate.model_validate(data)
90
+ except Exception as e:
91
+ raise UtcpSerializerValidationError(
92
+ f"Invalid GraphQLCallTemplate: {e}\n{traceback.format_exc()}"
93
+ )
@@ -0,0 +1,229 @@
1
+ import logging
2
+ from typing import Dict, Any, List, Optional, AsyncGenerator, TYPE_CHECKING
3
+
4
+ import aiohttp
5
+ from gql import Client as GqlClient, gql as gql_query
6
+ from gql.transport.aiohttp import AIOHTTPTransport
7
+
8
+ from utcp.interfaces.communication_protocol import CommunicationProtocol
9
+ from utcp.data.call_template import CallTemplate
10
+ from utcp.data.tool import Tool, JsonSchema
11
+ from utcp.data.utcp_manual import UtcpManual
12
+ from utcp.data.register_manual_response import RegisterManualResult
13
+ from utcp.data.auth_implementations.api_key_auth import ApiKeyAuth
14
+ from utcp.data.auth_implementations.basic_auth import BasicAuth
15
+ from utcp.data.auth_implementations.oauth2_auth import OAuth2Auth
16
+
17
+ from utcp_gql.gql_call_template import GraphQLCallTemplate
18
+
19
+ if TYPE_CHECKING:
20
+ from utcp.utcp_client import UtcpClient
21
+
22
+
23
+ logging.basicConfig(
24
+ level=logging.INFO,
25
+ format="%(asctime)s [%(levelname)s] %(filename)s:%(lineno)d - %(message)s",
26
+ )
27
+
28
+ logger = logging.getLogger(__name__)
29
+
30
+
31
+ class GraphQLCommunicationProtocol(CommunicationProtocol):
32
+ """GraphQL protocol implementation for UTCP 1.0.
33
+
34
+ - Discovers tools via GraphQL schema introspection.
35
+ - Executes per-call sessions using `gql` over HTTP(S).
36
+ - Supports `ApiKeyAuth`, `BasicAuth`, and `OAuth2Auth`.
37
+ - Enforces HTTPS or localhost for security.
38
+ """
39
+
40
+ def __init__(self) -> None:
41
+ self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
42
+
43
+ def _enforce_https_or_localhost(self, url: str) -> None:
44
+ if not (
45
+ url.startswith("https://")
46
+ or url.startswith("http://localhost")
47
+ or url.startswith("http://127.0.0.1")
48
+ ):
49
+ raise ValueError(
50
+ "Security error: URL must use HTTPS or start with 'http://localhost' or 'http://127.0.0.1'. "
51
+ "Non-secure URLs are vulnerable to man-in-the-middle attacks. "
52
+ f"Got: {url}."
53
+ )
54
+
55
+ async def _handle_oauth2(self, auth: OAuth2Auth) -> str:
56
+ client_id = auth.client_id
57
+ if client_id in self._oauth_tokens:
58
+ return self._oauth_tokens[client_id]["access_token"]
59
+ async with aiohttp.ClientSession() as session:
60
+ data = {
61
+ "grant_type": "client_credentials",
62
+ "client_id": client_id,
63
+ "client_secret": auth.client_secret,
64
+ "scope": auth.scope,
65
+ }
66
+ async with session.post(auth.token_url, data=data) as resp:
67
+ resp.raise_for_status()
68
+ token_response = await resp.json()
69
+ self._oauth_tokens[client_id] = token_response
70
+ return token_response["access_token"]
71
+
72
+ async def _prepare_headers(
73
+ self, call_template: GraphQLCallTemplate, tool_args: Optional[Dict[str, Any]] = None
74
+ ) -> Dict[str, str]:
75
+ headers: Dict[str, str] = call_template.headers.copy() if call_template.headers else {}
76
+ if call_template.auth:
77
+ if isinstance(call_template.auth, ApiKeyAuth):
78
+ if call_template.auth.api_key and call_template.auth.location == "header":
79
+ headers[call_template.auth.var_name] = call_template.auth.api_key
80
+ elif isinstance(call_template.auth, BasicAuth):
81
+ import base64
82
+
83
+ userpass = f"{call_template.auth.username}:{call_template.auth.password}"
84
+ headers["Authorization"] = "Basic " + base64.b64encode(userpass.encode()).decode()
85
+ elif isinstance(call_template.auth, OAuth2Auth):
86
+ token = await self._handle_oauth2(call_template.auth)
87
+ headers["Authorization"] = f"Bearer {token}"
88
+
89
+ # Map selected tool_args into headers if requested
90
+ if tool_args and call_template.header_fields:
91
+ for field in call_template.header_fields:
92
+ if field in tool_args and isinstance(tool_args[field], str):
93
+ headers[field] = tool_args[field]
94
+
95
+ return headers
96
+
97
+ async def register_manual(
98
+ self, caller: "UtcpClient", manual_call_template: CallTemplate
99
+ ) -> RegisterManualResult:
100
+ if not isinstance(manual_call_template, GraphQLCallTemplate):
101
+ raise ValueError("GraphQLCommunicationProtocol requires a GraphQLCallTemplate call template")
102
+ self._enforce_https_or_localhost(manual_call_template.url)
103
+
104
+ try:
105
+ headers = await self._prepare_headers(manual_call_template)
106
+ transport = AIOHTTPTransport(url=manual_call_template.url, headers=headers)
107
+ async with GqlClient(transport=transport, fetch_schema_from_transport=True) as session:
108
+ schema = session.client.schema
109
+ tools: List[Tool] = []
110
+
111
+ # Queries
112
+ if hasattr(schema, "query_type") and schema.query_type:
113
+ for name, field in schema.query_type.fields.items():
114
+ tools.append(
115
+ Tool(
116
+ name=name,
117
+ description=getattr(field, "description", "") or "",
118
+ inputs=JsonSchema(type="object"),
119
+ outputs=JsonSchema(type="object"),
120
+ tool_call_template=manual_call_template,
121
+ )
122
+ )
123
+
124
+ # Mutations
125
+ if hasattr(schema, "mutation_type") and schema.mutation_type:
126
+ for name, field in schema.mutation_type.fields.items():
127
+ tools.append(
128
+ Tool(
129
+ name=name,
130
+ description=getattr(field, "description", "") or "",
131
+ inputs=JsonSchema(type="object"),
132
+ outputs=JsonSchema(type="object"),
133
+ tool_call_template=manual_call_template,
134
+ )
135
+ )
136
+
137
+ # Subscriptions (listed for completeness)
138
+ if hasattr(schema, "subscription_type") and schema.subscription_type:
139
+ for name, field in schema.subscription_type.fields.items():
140
+ tools.append(
141
+ Tool(
142
+ name=name,
143
+ description=getattr(field, "description", "") or "",
144
+ inputs=JsonSchema(type="object"),
145
+ outputs=JsonSchema(type="object"),
146
+ tool_call_template=manual_call_template,
147
+ )
148
+ )
149
+
150
+ manual = UtcpManual(tools=tools)
151
+ return RegisterManualResult(
152
+ manual_call_template=manual_call_template,
153
+ manual=manual,
154
+ success=True,
155
+ errors=[],
156
+ )
157
+ except Exception as e:
158
+ logger.error(f"GraphQL manual registration failed for '{manual_call_template.name}': {e}")
159
+ return RegisterManualResult(
160
+ manual_call_template=manual_call_template,
161
+ manual=UtcpManual(manual_version="0.0.0", tools=[]),
162
+ success=False,
163
+ errors=[str(e)],
164
+ )
165
+
166
+ async def deregister_manual(
167
+ self, caller: "UtcpClient", manual_call_template: CallTemplate
168
+ ) -> None:
169
+ # Stateless: nothing to clean up
170
+ return None
171
+
172
+ async def call_tool(
173
+ self,
174
+ caller: "UtcpClient",
175
+ tool_name: str,
176
+ tool_args: Dict[str, Any],
177
+ tool_call_template: CallTemplate,
178
+ ) -> Any:
179
+ if not isinstance(tool_call_template, GraphQLCallTemplate):
180
+ raise ValueError("GraphQLCommunicationProtocol requires a GraphQLCallTemplate call template")
181
+ self._enforce_https_or_localhost(tool_call_template.url)
182
+
183
+ headers = await self._prepare_headers(tool_call_template, tool_args)
184
+ transport = AIOHTTPTransport(url=tool_call_template.url, headers=headers)
185
+ async with GqlClient(transport=transport, fetch_schema_from_transport=True) as session:
186
+ # Filter out header fields from GraphQL variables; these are sent via HTTP headers
187
+ header_fields = tool_call_template.header_fields or []
188
+ filtered_args = {k: v for k, v in tool_args.items() if k not in header_fields}
189
+
190
+ # Use custom query if provided (highest flexibility for agents)
191
+ if tool_call_template.query:
192
+ gql_str = tool_call_template.query
193
+ else:
194
+ # Auto-generate query - use variable_types for proper typing
195
+ op_type = getattr(tool_call_template, "operation_type", "query")
196
+ base_tool_name = tool_name.split(".", 1)[-1] if "." in tool_name else tool_name
197
+ variable_types = tool_call_template.variable_types or {}
198
+
199
+ # Build variable definitions with proper types (default to String)
200
+ arg_str = ", ".join(
201
+ f"${k}: {variable_types.get(k, 'String')}"
202
+ for k in filtered_args.keys()
203
+ )
204
+ var_defs = f"({arg_str})" if arg_str else ""
205
+ arg_pass = ", ".join(f"{k}: ${k}" for k in filtered_args.keys())
206
+ arg_pass = f"({arg_pass})" if arg_pass else ""
207
+
208
+ # Note: Auto-generated queries for object-returning fields will still fail
209
+ # without a selection set. Use the `query` field for full control.
210
+ gql_str = f"{op_type} {var_defs} {{ {base_tool_name}{arg_pass} }}"
211
+ logger.debug(f"Auto-generated GraphQL: {gql_str}")
212
+
213
+ document = gql_query(gql_str)
214
+ result = await session.execute(document, variable_values=filtered_args)
215
+ return result
216
+
217
+ async def call_tool_streaming(
218
+ self,
219
+ caller: "UtcpClient",
220
+ tool_name: str,
221
+ tool_args: Dict[str, Any],
222
+ tool_call_template: CallTemplate,
223
+ ) -> AsyncGenerator[Any, None]:
224
+ # Basic implementation: execute non-streaming and yield once
225
+ result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
226
+ yield result
227
+
228
+ async def close(self) -> None:
229
+ self._oauth_tokens.clear()
@@ -0,0 +1,73 @@
1
+ Metadata-Version: 2.4
2
+ Name: utcp-gql
3
+ Version: 1.0.2
4
+ Summary: UTCP communication protocol plugin for GraphQL. (Work in progress)
5
+ Author: UTCP Contributors
6
+ License-Expression: MPL-2.0
7
+ Project-URL: Homepage, https://utcp.io
8
+ Project-URL: Source, https://github.com/universal-tool-calling-protocol/python-utcp
9
+ Project-URL: Issues, https://github.com/universal-tool-calling-protocol/python-utcp/issues
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Operating System :: OS Independent
14
+ Requires-Python: >=3.10
15
+ Description-Content-Type: text/markdown
16
+ Requires-Dist: pydantic>=2.0
17
+ Requires-Dist: gql>=3.0
18
+ Requires-Dist: utcp>=1.0
19
+ Provides-Extra: dev
20
+ Requires-Dist: build; extra == "dev"
21
+ Requires-Dist: pytest; extra == "dev"
22
+ Requires-Dist: pytest-asyncio; extra == "dev"
23
+ Requires-Dist: pytest-cov; extra == "dev"
24
+ Requires-Dist: coverage; extra == "dev"
25
+ Requires-Dist: twine; extra == "dev"
26
+
27
+
28
+ # UTCP GraphQL Communication Protocol Plugin
29
+
30
+ This plugin integrates GraphQL as a UTCP 1.0 communication protocol and call template. It supports discovery via schema introspection, authenticated calls, and header handling.
31
+
32
+ ## Getting Started
33
+
34
+ ### Installation
35
+
36
+ ```bash
37
+ pip install gql
38
+ ```
39
+
40
+ ### Registration
41
+
42
+ ```python
43
+ import utcp_gql
44
+ utcp_gql.register()
45
+ ```
46
+
47
+ ## How To Use
48
+
49
+ - Ensure the plugin is imported and registered: `import utcp_gql; utcp_gql.register()`.
50
+ - Add a manual in your client config:
51
+ ```json
52
+ {
53
+ "name": "my_graph",
54
+ "call_template_type": "graphql",
55
+ "url": "https://your.graphql/endpoint",
56
+ "operation_type": "query",
57
+ "headers": { "x-client": "utcp" },
58
+ "header_fields": ["x-session-id"]
59
+ }
60
+ ```
61
+ - Call a tool:
62
+ ```python
63
+ await client.call_tool("my_graph.someQuery", {"id": "123", "x-session-id": "abc"})
64
+ ```
65
+
66
+ ## Notes
67
+
68
+ - Tool names are prefixed by the manual name (e.g., `my_graph.someQuery`).
69
+ - Headers merge static `headers` plus whitelisted dynamic fields from `header_fields`.
70
+ - Supported auth: API key, Basic auth, OAuth2 (client-credentials).
71
+ - Security: only `https://` or `http://localhost`/`http://127.0.0.1` endpoints.
72
+
73
+ For UTCP core docs, see https://github.com/universal-tool-calling-protocol/python-utcp.
@@ -0,0 +1,11 @@
1
+ README.md
2
+ pyproject.toml
3
+ src/utcp_gql/__init__.py
4
+ src/utcp_gql/gql_call_template.py
5
+ src/utcp_gql/gql_communication_protocol.py
6
+ src/utcp_gql.egg-info/PKG-INFO
7
+ src/utcp_gql.egg-info/SOURCES.txt
8
+ src/utcp_gql.egg-info/dependency_links.txt
9
+ src/utcp_gql.egg-info/requires.txt
10
+ src/utcp_gql.egg-info/top_level.txt
11
+ tests/test_graphql_integration.py
@@ -0,0 +1,11 @@
1
+ pydantic>=2.0
2
+ gql>=3.0
3
+ utcp>=1.0
4
+
5
+ [dev]
6
+ build
7
+ pytest
8
+ pytest-asyncio
9
+ pytest-cov
10
+ coverage
11
+ twine
@@ -0,0 +1 @@
1
+ utcp_gql
@@ -0,0 +1,275 @@
1
+ """Integration tests for GraphQL communication protocol using real GraphQL servers.
2
+
3
+ Uses the public Countries API (https://countries.trevorblades.com/graphql) which
4
+ requires no authentication and has a stable schema.
5
+ """
6
+ import os
7
+ import sys
8
+ import warnings
9
+ import pytest
10
+ import pytest_asyncio
11
+
12
+ # Ensure plugin src is importable
13
+ PLUGIN_SRC = os.path.join(os.path.dirname(__file__), "..", "src")
14
+ PLUGIN_SRC = os.path.abspath(PLUGIN_SRC)
15
+ if PLUGIN_SRC not in sys.path:
16
+ sys.path.append(PLUGIN_SRC)
17
+
18
+ import utcp_gql
19
+ from utcp_gql.gql_call_template import GraphQLCallTemplate
20
+ from utcp_gql.gql_communication_protocol import GraphQLCommunicationProtocol
21
+
22
+ from utcp.implementations.utcp_client_implementation import UtcpClientImplementation
23
+
24
+ # Public GraphQL API for testing (no auth required)
25
+ COUNTRIES_API_URL = "https://countries.trevorblades.com/graphql"
26
+
27
+ # Suppress gql SSL warning (we're using HTTPS which is secure)
28
+ warnings.filterwarnings("ignore", message=".*AIOHTTPTransport does not verify ssl.*")
29
+
30
+
31
+ @pytest.fixture
32
+ def protocol():
33
+ """Create a fresh GraphQL protocol instance."""
34
+ utcp_gql.register()
35
+ return GraphQLCommunicationProtocol()
36
+
37
+
38
+ @pytest_asyncio.fixture
39
+ async def client():
40
+ """Create a minimal UTCP client."""
41
+ return await UtcpClientImplementation.create()
42
+
43
+
44
+ @pytest.mark.asyncio
45
+ async def test_register_manual_discovers_tools(protocol, client):
46
+ """Test that register_manual discovers tools from a real GraphQL schema."""
47
+ template = GraphQLCallTemplate(
48
+ name="countries_api",
49
+ url=COUNTRIES_API_URL,
50
+ )
51
+
52
+ result = await protocol.register_manual(client, template)
53
+
54
+ assert result.success is True
55
+ assert len(result.manual.tools) > 0
56
+
57
+ # The Countries API should have these common queries
58
+ tool_names = [t.name for t in result.manual.tools]
59
+ assert "countries" in tool_names or "country" in tool_names
60
+
61
+
62
+ @pytest.mark.asyncio
63
+ async def test_call_tool_with_custom_query(protocol, client):
64
+ """Test calling a tool with a custom query string (fixes selection set issue)."""
65
+ # Custom query with proper selection set - this is the UTCP-flexible approach
66
+ custom_query = """
67
+ query GetCountry($code: ID!) {
68
+ country(code: $code) {
69
+ name
70
+ capital
71
+ currency
72
+ }
73
+ }
74
+ """
75
+
76
+ template = GraphQLCallTemplate(
77
+ name="countries_api",
78
+ url=COUNTRIES_API_URL,
79
+ query=custom_query,
80
+ )
81
+
82
+ result = await protocol.call_tool(
83
+ client,
84
+ "country",
85
+ {"code": "US"},
86
+ template,
87
+ )
88
+
89
+ assert result is not None
90
+ assert "country" in result
91
+ assert result["country"]["name"] == "United States"
92
+ assert result["country"]["capital"] == "Washington D.C."
93
+
94
+
95
+ @pytest.mark.asyncio
96
+ async def test_call_tool_with_variable_types(protocol, client):
97
+ """Test that variable_types properly maps GraphQL types (fixes String-only issue)."""
98
+ # The country query expects code: ID!, not String
99
+ # Using variable_types to specify the correct type
100
+ custom_query = """
101
+ query GetCountry($code: ID!) {
102
+ country(code: $code) {
103
+ name
104
+ emoji
105
+ }
106
+ }
107
+ """
108
+
109
+ template = GraphQLCallTemplate(
110
+ name="countries_api",
111
+ url=COUNTRIES_API_URL,
112
+ query=custom_query,
113
+ variable_types={"code": "ID!"},
114
+ )
115
+
116
+ result = await protocol.call_tool(
117
+ client,
118
+ "country",
119
+ {"code": "FR"},
120
+ template,
121
+ )
122
+
123
+ assert result is not None
124
+ assert result["country"]["name"] == "France"
125
+ assert result["country"]["emoji"] == "🇫🇷"
126
+
127
+
128
+ @pytest.mark.asyncio
129
+ async def test_call_tool_list_query(protocol, client):
130
+ """Test querying a list of items with proper selection set."""
131
+ custom_query = """
132
+ query GetContinents {
133
+ continents {
134
+ code
135
+ name
136
+ }
137
+ }
138
+ """
139
+
140
+ template = GraphQLCallTemplate(
141
+ name="countries_api",
142
+ url=COUNTRIES_API_URL,
143
+ query=custom_query,
144
+ )
145
+
146
+ result = await protocol.call_tool(
147
+ client,
148
+ "continents",
149
+ {},
150
+ template,
151
+ )
152
+
153
+ assert result is not None
154
+ assert "continents" in result
155
+ assert len(result["continents"]) == 7 # 7 continents
156
+
157
+ continent_names = [c["name"] for c in result["continents"]]
158
+ assert "Europe" in continent_names
159
+ assert "Asia" in continent_names
160
+
161
+
162
+ @pytest.mark.asyncio
163
+ async def test_call_tool_nested_query(protocol, client):
164
+ """Test querying nested objects with proper selection sets."""
165
+ custom_query = """
166
+ query GetCountryWithLanguages($code: ID!) {
167
+ country(code: $code) {
168
+ name
169
+ languages {
170
+ code
171
+ name
172
+ }
173
+ }
174
+ }
175
+ """
176
+
177
+ template = GraphQLCallTemplate(
178
+ name="countries_api",
179
+ url=COUNTRIES_API_URL,
180
+ query=custom_query,
181
+ )
182
+
183
+ result = await protocol.call_tool(
184
+ client,
185
+ "country",
186
+ {"code": "CH"}, # Switzerland - has multiple languages
187
+ template,
188
+ )
189
+
190
+ assert result is not None
191
+ assert result["country"]["name"] == "Switzerland"
192
+ assert len(result["country"]["languages"]) >= 3 # German, French, Italian, Romansh
193
+
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_call_tool_with_filter_arguments(protocol, client):
197
+ """Test queries with filter arguments using proper types."""
198
+ custom_query = """
199
+ query GetCountriesByContinent($filter: CountryFilterInput) {
200
+ countries(filter: $filter) {
201
+ code
202
+ name
203
+ }
204
+ }
205
+ """
206
+
207
+ template = GraphQLCallTemplate(
208
+ name="countries_api",
209
+ url=COUNTRIES_API_URL,
210
+ query=custom_query,
211
+ variable_types={"filter": "CountryFilterInput"},
212
+ )
213
+
214
+ result = await protocol.call_tool(
215
+ client,
216
+ "countries",
217
+ {"filter": {"continent": {"eq": "EU"}}},
218
+ template,
219
+ )
220
+
221
+ assert result is not None
222
+ assert "countries" in result
223
+ # Should return European countries
224
+ country_codes = [c["code"] for c in result["countries"]]
225
+ assert "DE" in country_codes # Germany
226
+ assert "FR" in country_codes # France
227
+
228
+
229
+ @pytest.mark.asyncio
230
+ async def test_error_handling_invalid_query(protocol, client):
231
+ """Test that invalid queries return proper errors."""
232
+ # Invalid query syntax
233
+ invalid_query = "this is not valid graphql"
234
+
235
+ template = GraphQLCallTemplate(
236
+ name="countries_api",
237
+ url=COUNTRIES_API_URL,
238
+ query=invalid_query,
239
+ )
240
+
241
+ with pytest.raises(Exception):
242
+ await protocol.call_tool(
243
+ client,
244
+ "invalid",
245
+ {},
246
+ template,
247
+ )
248
+
249
+
250
+ @pytest.mark.asyncio
251
+ async def test_error_handling_missing_selection_set_auto_generated(protocol, client):
252
+ """
253
+ Demonstrate that auto-generated queries fail for object-returning fields.
254
+
255
+ This test documents the limitation: without a custom query, object fields fail.
256
+ The fix is to always use the `query` field for object-returning operations.
257
+ """
258
+ # No custom query - will auto-generate without selection set
259
+ template = GraphQLCallTemplate(
260
+ name="countries_api",
261
+ url=COUNTRIES_API_URL,
262
+ operation_type="query",
263
+ variable_types={"code": "ID!"},
264
+ )
265
+
266
+ # This should fail because auto-generated query lacks selection set
267
+ # The query becomes: query ($code: ID!) { country(code: $code) }
268
+ # But country returns an object that needs: { name capital ... }
269
+ with pytest.raises(Exception):
270
+ await protocol.call_tool(
271
+ client,
272
+ "country",
273
+ {"code": "US"},
274
+ template,
275
+ )