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.
- utcp_gql-1.0.2/PKG-INFO +73 -0
- utcp_gql-1.0.2/README.md +47 -0
- utcp_gql-1.0.2/pyproject.toml +40 -0
- utcp_gql-1.0.2/setup.cfg +4 -0
- utcp_gql-1.0.2/src/utcp_gql/__init__.py +9 -0
- utcp_gql-1.0.2/src/utcp_gql/gql_call_template.py +93 -0
- utcp_gql-1.0.2/src/utcp_gql/gql_communication_protocol.py +229 -0
- utcp_gql-1.0.2/src/utcp_gql.egg-info/PKG-INFO +73 -0
- utcp_gql-1.0.2/src/utcp_gql.egg-info/SOURCES.txt +11 -0
- utcp_gql-1.0.2/src/utcp_gql.egg-info/dependency_links.txt +1 -0
- utcp_gql-1.0.2/src/utcp_gql.egg-info/requires.txt +11 -0
- utcp_gql-1.0.2/src/utcp_gql.egg-info/top_level.txt +1 -0
- utcp_gql-1.0.2/tests/test_graphql_integration.py +275 -0
utcp_gql-1.0.2/PKG-INFO
ADDED
|
@@ -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.
|
utcp_gql-1.0.2/README.md
ADDED
|
@@ -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"
|
utcp_gql-1.0.2/setup.cfg
ADDED
|
@@ -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 @@
|
|
|
1
|
+
|
|
@@ -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
|
+
)
|