Skip to content

Conversation

@Thuraabtech
Copy link
Contributor

@Thuraabtech Thuraabtech commented Dec 14, 2025

This PR adds a gNMI (gRPC) protocol plugin fully compatible with UTCP 1.0 and its protocol/call-template architecture. It implements discovery, calling (unary), streaming subscribe, authentication, registration, and security alignment consistent with other UTCP plugins.

Protocol Implementation

  • Added GnmiCommunicationProtocol conforming to UTCP’s CommunicationProtocol interface

    • Discovery registers canonical gNMI operations as UTCP Tools: capabilities/get/set/subscribe at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:28–61
    • Enforces TLS unless target is localhost at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:16–26
    • Implements call_tool() for unary RPCs (Capabilities/Get/Set) using grpc.aio at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:81–130
    • Implements call_tool_streaming() for subscribe with async iteration at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:132–134 and following
  • Auth injection into gRPC metadata

    • API Key, Basic, OAuth2 (client credentials) mapped to authorization and custom keys at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:86–104
    • OAuth2 helper for token retrieval at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:106–134
  • Protobuf request binding

    • Fixed Get path construction ( Path.elem appended correctly) at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:102–114
    • Set builds Update entries with TypedValue at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:116–126
      Call Template & Serializer
  • Introduced GnmiCallTemplate with:

    • call_template_type: "gnmi" , target , use_tls , metadata , metadata_fields , operation , stub_module , message_module at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_call_template.py:9–17
    • operation supports "capabilities" | "get" | "set" | "subscribe" at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_call_template.py:15
  • Added GnmiCallTemplateSerializer that validates and serializes the template at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_call_template.py:19–27
    Registration

  • Registers both protocol and call template via UTCP plugin system:

    • plugins/communication_protocols/gnmi/src/utcp_gnmi/init.py:5–12
  • Packaging entry point configured:

    • plugins/communication_protocols/gnmi/pyproject.toml:47–49
      Security & Validation
  • TLS enforcement unless target is localhost / 127.0.0.1 at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:16–26

  • Merges static metadata and whitelisted dynamic metadata_fields into gRPC call metadata at plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:73–80

  • Proper error propagation via UTCP client routing in core/src/utcp/implementations/utcp_client_implementation.py:176–197

Testing

  • Run: python -m pytest plugins/communication_protocols/gnmi/tests/test_gnmi_plugin.py -q
  • Tests:
    • Verifies manual registration and presence of tools (including subscribe )
    • Validates serializer round-trip for GnmiCallTemplate
  • Result: 2 passed; deprecation warnings only

Summary by cubic

Adds a gNMI (gRPC) communication protocol plugin for UTCP 1.0 with secure discovery, unary calls (Capabilities/Get/Set), and streaming Subscribe. Also modernizes GraphQL and socket protocols for UTCP 1.0 compatibility and better reliability.

  • New Features

    • gNMI plugin: tools for capabilities/get/set/subscribe, TLS required except localhost, auth via API key/Basic/OAuth2, dynamic metadata fields, protobuf request binding, registration and serializer, tests included.
  • Refactors

    • GraphQL: moved to UTCP 1.0 protocol interface with schema introspection discovery, HTTPS/localhost enforcement, auth and header merging, streaming wrapper, README and tests.
    • Socket (TCP): added interpret_escape_sequences flag for delimiters, clearer errors on unknown framing, test/script hardening.
    • UDP: safer tool_call_template normalization with validation and logged fallbacks.
    • Dependencies: MCP plugin updates langchain to >=0.3.27,<0.4.0.

Written for commit 9bbb819. Summary will update automatically on new commits.

h3xxit and others added 22 commits August 26, 2025 17:03
…tool-calling-protocol/dev

Add docs and update http to 1.0.2
…tool-calling-protocol/dev

Fix response json parsing when content type is wrong
…om universal-tool-calling-protocol/dev

Update CLI
…tool-calling-protocol/dev

Plugin updates
…tool-calling-protocol/dev

  Add WebSocket transport implementation for real-time communication …
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings December 14, 2025 18:52
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a gNMI (gRPC Network Management Interface) communication protocol plugin to UTCP 1.0, alongside improvements to existing protocol plugins (GraphQL, TCP/UDP sockets) and dependency updates. The gNMI plugin enables network device management via gRPC, implementing discovery, unary calls (capabilities/get/set), streaming subscriptions, and authentication compatible with UTCP's architecture.

Key Changes:

  • New gNMI protocol plugin with support for TLS, OAuth2/Basic/API key auth, and streaming subscriptions
  • GraphQL plugin refactored to UTCP 1.0 architecture with proper registration and improved header handling
  • Socket protocols enhanced with better exception handling, delimiter escape sequence control, and error validation
  • Dependency relaxation for MCP plugin's langchain requirement

Reviewed changes

Copilot reviewed 16 out of 16 changed files in this pull request and generated 15 comments.

Show a summary per file
File Description
plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py Core gNMI protocol implementation with gRPC channel management, metadata/auth handling, and operation routing
plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_call_template.py gNMI call template and serializer definitions
plugins/communication_protocols/gnmi/src/utcp_gnmi/__init__.py Plugin registration entry point
plugins/communication_protocols/gnmi/tests/test_gnmi_plugin.py Tests for manual registration and serializer roundtrip
plugins/communication_protocols/gnmi/pyproject.toml Package configuration with gRPC/protobuf dependencies
plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py Refactored GraphQL protocol to UTCP 1.0 with improved auth and header field mapping
plugins/communication_protocols/gql/src/utcp_gql/gql_call_template.py Added auth serialization/validation to GraphQL provider
plugins/communication_protocols/gql/src/utcp_gql/__init__.py Plugin registration for GraphQL protocol
plugins/communication_protocols/gql/tests/test_graphql_protocol.py New comprehensive tests for GraphQL protocol registration and tool calling
plugins/communication_protocols/gql/README.md Documentation for GraphQL plugin usage
plugins/communication_protocols/socket/src/utcp_socket/tcp_communication_protocol.py Enhanced delimiter handling with escape sequence interpretation flag and unknown framing strategy validation
plugins/communication_protocols/socket/src/utcp_socket/tcp_call_template.py Added interpret_escape_sequences field for delimiter configuration
plugins/communication_protocols/socket/src/utcp_socket/udp_communication_protocol.py Improved exception handling with specific exception types and logging
plugins/communication_protocols/socket/tests/test_tcp_communication_protocol.py Added explanatory comments for exception handling in test server
plugins/communication_protocols/mcp/pyproject.toml Relaxed langchain dependency from exact pin to range constraint
scripts/socket_sanity.py Added explanatory comments for exception handling and updated success message
Comments suppressed due to low confidence (1)

plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:144

  • Variable mode is not used.
            mode = tool_args.get("mode", "stream").upper()

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

from utcp.data.auth_implementations.basic_auth import BasicAuth
from utcp.data.auth_implementations.oauth2_auth import OAuth2Auth

class GnmiCommunicationProtocol(CommunicationProtocol):
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The class is missing an __init__ method to initialize the _oauth_tokens dictionary that would be used for OAuth2 token caching (referenced in comment ID 006). Without this initialization, the _handle_oauth2 method will fail with an AttributeError when it tries to access self._oauth_tokens. Add an __init__ method that initializes self._oauth_tokens = {}.

Suggested change
class GnmiCommunicationProtocol(CommunicationProtocol):
class GnmiCommunicationProtocol(CommunicationProtocol):
def __init__(self):
super().__init__()
self._oauth_tokens = {}

Copilot uses AI. Check for mistakes.
{ name = "UTCP Contributors" },
]
description = "UTCP gNMI communication protocol plugin over gRPC"
readme = "README.md"
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The pyproject.toml references a README.md file that does not exist in the gnmi plugin directory. This will cause packaging issues. Either create a README.md file with appropriate documentation (similar to the GraphQL plugin's README) or remove the readme field from the project configuration.

Suggested change
readme = "README.md"

Copilot uses AI. Check for mistakes.
Comment on lines 69 to 159
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
if not isinstance(tool_call_template, GnmiCallTemplate):
raise ValueError("GnmiCommunicationProtocol can only be used with GnmiCallTemplate")

op = tool_call_template.operation
target = tool_call_template.target

metadata: List[tuple[str, str]] = []
if tool_call_template.metadata:
metadata.extend([(k, v) for k, v in tool_call_template.metadata.items()])
if tool_call_template.metadata_fields:
for k in tool_call_template.metadata_fields:
if k in tool_args:
metadata.append((k, str(tool_args[k])))
if tool_call_template.auth:
if isinstance(tool_call_template.auth, ApiKeyAuth):
if tool_call_template.auth.api_key:
metadata.append((tool_call_template.auth.var_name or "authorization", tool_call_template.auth.api_key))
elif isinstance(tool_call_template.auth, BasicAuth):
import base64
token = base64.b64encode(f"{tool_call_template.auth.username}:{tool_call_template.auth.password}".encode()).decode()
metadata.append(("authorization", f"Basic {token}"))
elif isinstance(tool_call_template.auth, OAuth2Auth):
token = await self._handle_oauth2(tool_call_template.auth)
metadata.append(("authorization", f"Bearer {token}"))

grpc = importlib.import_module("grpc")
aio = importlib.import_module("grpc.aio")
json_format = importlib.import_module("google.protobuf.json_format")
stub_mod = importlib.import_module(tool_call_template.stub_module)
msg_mod = importlib.import_module(tool_call_template.message_module)

if tool_call_template.use_tls:
creds = grpc.ssl_channel_credentials()
channel = aio.secure_channel(target, creds)
else:
channel = aio.insecure_channel(target)

stub = None
for attr in dir(stub_mod):
if attr.endswith("Stub"):
stub_cls = getattr(stub_mod, attr)
stub = stub_cls(channel)
break
if stub is None:
raise ValueError("gNMI stub not found in stub_module")

if op == "capabilities":
req = getattr(msg_mod, "CapabilityRequest")()
resp = await stub.Capabilities(req, metadata=metadata)
elif op == "get":
req = getattr(msg_mod, "GetRequest")()
paths = tool_args.get("paths", [])
for p in paths:
path_msg = getattr(msg_mod, "Path")()
for elem in [e for e in p.strip("/").split("/") if e]:
pe = getattr(msg_mod, "PathElem")(name=elem)
path_msg.elem.append(pe)
req.path.append(path_msg)
resp = await stub.Get(req, metadata=metadata)
elif op == "set":
req = getattr(msg_mod, "SetRequest")()
updates = tool_args.get("updates", [])
for upd in updates:
path_msg = getattr(msg_mod, "Path")()
for elem in [e for e in str(upd.get("path", "")).strip("/").split("/") if e]:
pe = getattr(msg_mod, "PathElem")(name=elem)
path_msg.elem.append(pe)
val = getattr(msg_mod, "TypedValue")(json_val=str(upd.get("value", "")))
update_msg = getattr(msg_mod, "Update")(path=path_msg, val=val)
req.update.append(update_msg)
resp = await stub.Set(req, metadata=metadata)
elif op == "subscribe":
req = getattr(msg_mod, "SubscribeRequest")()
sub_list = getattr(msg_mod, "SubscriptionList")()
mode = tool_args.get("mode", "stream").upper()
sub_list.mode = getattr(msg_mod, "SubscriptionList".upper(), None) or 0
paths = tool_args.get("paths", [])
for p in paths:
path_msg = getattr(msg_mod, "Path")()
for elem in [e for e in p.strip("/").split("/") if e]:
pe = getattr(msg_mod, "PathElem")(name=elem)
path_msg.elem.append(pe)
sub = getattr(msg_mod, "Subscription")(path=path_msg)
sub_list.subscription.append(sub)
req.subscribe.CopyFrom(sub_list)
raise ValueError("Subscribe is streaming; use call_tool_streaming")
else:
raise ValueError("Unsupported gNMI operation")

return json_format.MessageToDict(resp)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gRPC channel created on lines 101-105 is never closed. gRPC channels should be properly closed after use to avoid resource leaks. Consider wrapping the channel usage in a try-finally block or using async context manager pattern to ensure the channel is closed.

Copilot uses AI. Check for mistakes.
Comment on lines 161 to 218
async def call_tool_streaming(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> AsyncGenerator[Any, None]:
if not isinstance(tool_call_template, GnmiCallTemplate):
raise ValueError("GnmiCommunicationProtocol can only be used with GnmiCallTemplate")
if tool_call_template.operation != "subscribe":
result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
yield result
return
grpc = importlib.import_module("grpc")
aio = importlib.import_module("grpc.aio")
json_format = importlib.import_module("google.protobuf.json_format")
stub_mod = importlib.import_module(tool_call_template.stub_module)
msg_mod = importlib.import_module(tool_call_template.message_module)
target = tool_call_template.target
if tool_call_template.use_tls:
creds = grpc.ssl_channel_credentials()
channel = aio.secure_channel(target, creds)
else:
channel = aio.insecure_channel(target)
stub = None
for attr in dir(stub_mod):
if attr.endswith("Stub"):
stub_cls = getattr(stub_mod, attr)
stub = stub_cls(channel)
break
if stub is None:
raise ValueError("gNMI stub not found in stub_module")
metadata: List[tuple[str, str]] = []
if tool_call_template.metadata:
metadata.extend([(k, v) for k, v in tool_call_template.metadata.items()])
if tool_call_template.metadata_fields:
for k in tool_call_template.metadata_fields:
if k in tool_args:
metadata.append((k, str(tool_args[k])))
if tool_call_template.auth:
if isinstance(tool_call_template.auth, ApiKeyAuth):
if tool_call_template.auth.api_key:
metadata.append((tool_call_template.auth.var_name or "authorization", tool_call_template.auth.api_key))
elif isinstance(tool_call_template.auth, BasicAuth):
import base64
token = base64.b64encode(f"{tool_call_template.auth.username}:{tool_call_template.auth.password}".encode()).decode()
metadata.append(("authorization", f"Basic {token}"))
elif isinstance(tool_call_template.auth, OAuth2Auth):
token = await self._handle_oauth2(tool_call_template.auth)
metadata.append(("authorization", f"Bearer {token}"))
req = getattr(msg_mod, "SubscribeRequest")()
sub_list = getattr(msg_mod, "SubscriptionList")()
paths = tool_args.get("paths", [])
for p in paths:
path_msg = getattr(msg_mod, "Path")()
for elem in [e for e in p.strip("/").split("/") if e]:
pe = getattr(msg_mod, "PathElem")(name=elem)
path_msg.elem.append(pe)
sub = getattr(msg_mod, "Subscription")(path=path_msg)
sub_list.subscription.append(sub)
req.subscribe.CopyFrom(sub_list)
call = stub.Subscribe(req, metadata=metadata)
async for resp in call:
yield json_format.MessageToDict(resp)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gRPC channel created on lines 174-178 is never closed. gRPC channels should be properly closed after use to avoid resource leaks. Consider wrapping the channel usage in a try-finally block or using async context manager pattern to ensure the channel is closed even if the streaming operation is interrupted.

Copilot uses AI. Check for mistakes.
req = getattr(msg_mod, "SubscribeRequest")()
sub_list = getattr(msg_mod, "SubscriptionList")()
mode = tool_args.get("mode", "stream").upper()
sub_list.mode = getattr(msg_mod, "SubscriptionList".upper(), None) or 0
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The getattr(msg_mod, "SubscriptionList".upper(), None) is incorrect and will always evaluate to None. It attempts to get an attribute named "SUBSCRIPTIONLIST" (all uppercase string literal) instead of the subscription mode enum value. This should likely retrieve the subscription mode from the message module based on the mode variable, such as getattr(msg_mod, f"SubscriptionList.Mode.{mode}", 0) or similar, depending on the protobuf structure.

Suggested change
sub_list.mode = getattr(msg_mod, "SubscriptionList".upper(), None) or 0
# Set the mode enum value correctly
try:
mode_enum = getattr(getattr(msg_mod, "SubscriptionList"), "Mode")
sub_list.mode = getattr(mode_enum, mode, 0)
except AttributeError:
sub_list.mode = 0

Copilot uses AI. Check for mistakes.
Comment on lines 76 to 93
metadata: List[tuple[str, str]] = []
if tool_call_template.metadata:
metadata.extend([(k, v) for k, v in tool_call_template.metadata.items()])
if tool_call_template.metadata_fields:
for k in tool_call_template.metadata_fields:
if k in tool_args:
metadata.append((k, str(tool_args[k])))
if tool_call_template.auth:
if isinstance(tool_call_template.auth, ApiKeyAuth):
if tool_call_template.auth.api_key:
metadata.append((tool_call_template.auth.var_name or "authorization", tool_call_template.auth.api_key))
elif isinstance(tool_call_template.auth, BasicAuth):
import base64
token = base64.b64encode(f"{tool_call_template.auth.username}:{tool_call_template.auth.password}".encode()).decode()
metadata.append(("authorization", f"Basic {token}"))
elif isinstance(tool_call_template.auth, OAuth2Auth):
token = await self._handle_oauth2(tool_call_template.auth)
metadata.append(("authorization", f"Bearer {token}"))
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The metadata construction logic (lines 76-93) is duplicated in both call_tool and call_tool_streaming (lines 187-204). This duplication creates a maintenance burden and increases the risk of inconsistencies. Consider extracting this into a private helper method like _build_metadata to eliminate the duplication.

Copilot uses AI. Check for mistakes.
Comment on lines 20 to 22
if manual_call_template.use_tls:
pass
else:
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty pass statement in the TLS branch serves no purpose and makes the code harder to read. Consider removing it or adding a comment explaining why TLS is always allowed.

Suggested change
if manual_call_template.use_tls:
pass
else:
if not manual_call_template.use_tls:

Copilot uses AI. Check for mistakes.

async def _handle_oauth2(self, auth_details: OAuth2Auth) -> str:
import aiohttp
client_id = auth_details.client_id
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Variable client_id is not used.

Suggested change
client_id = auth_details.client_id

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,27 @@
from typing import Optional, Dict, List, Literal
from pydantic import Field
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'Field' is not used.

Suggested change
from pydantic import Field

Copilot uses AI. Check for mistakes.
@@ -0,0 +1,245 @@
import importlib
from typing import Dict, Any, List, Optional, AsyncGenerator
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Import of 'Optional' is not used.

Suggested change
from typing import Dict, Any, List, Optional, AsyncGenerator
from typing import Dict, Any, List, AsyncGenerator

Copilot uses AI. Check for mistakes.
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

7 issues found across 16 files

Prompt for AI agents (all 7 issues)

Check if these issues are valid — if so, understand the root cause of each and fix them.


<file name="plugins/communication_protocols/gql/README.md">

<violation number="1" location="plugins/communication_protocols/gql/README.md:12">
P1: Incorrect package name in installation instructions. The package name is `utcp-gql` (as defined in pyproject.toml), not `gql`. The `gql` package is a dependency, not the plugin itself.</violation>
</file>

<file name="plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py">

<violation number="1" location="plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:23">
P1: [gpt-5.2] The localhost check is bypassable via `startswith(...)` (e.g., `localhost.evil.com`). Parse the hostname and compare exact loopback hosts before allowing insecure channels.</violation>

<violation number="2" location="plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:103">
P2: Resource leak: gRPC channel is created but never closed. Consider using `async with` context manager or explicitly calling `await channel.close()` after the RPC call completes to prevent connection leaks.</violation>

<violation number="3" location="plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:137">
P1: [gpt-5.2] TypedValue(json_val=...) is constructed with a Python string, which is not valid for gNMI JSON bytes fields and can break Set RPCs. Serialize values to real JSON bytes (prefer json_ietf_val when available) or fall back to string_val.</violation>

<violation number="4" location="plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py:145">
P1: [gpt-5.2] Subscribe `mode` is effectively ignored due to an incorrect `getattr(...)` target; this will likely always set mode to 0. Map the requested mode to the generated enum constant (with a safe default).</violation>
</file>

<file name="plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py">

<violation number="1" location="plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py:193">
P2: [gpt-5.2] Declaring every variable as `String` can break calls for non-String argument types (ID/Int/Boolean/input objects). Infer variable types from the fetched schema (or otherwise avoid hard-coding String).</violation>

<violation number="2" location="plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py:210">
P2: [gpt-5.2] call_tool_streaming currently isn’t truly streaming (it yields once). At minimum, fail fast for subscription operation_type or implement real GraphQL subscription streaming.</violation>
</file>

Reply to cubic to teach it or ask questions. Re-run a review with @cubic-dev-ai review this PR


```bash
pip install gql
```
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Incorrect package name in installation instructions. The package name is utcp-gql (as defined in pyproject.toml), not gql. The gql package is a dependency, not the plugin itself.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gql/README.md, line 12:

<comment>Incorrect package name in installation instructions. The package name is `utcp-gql` (as defined in pyproject.toml), not `gql`. The `gql` package is a dependency, not the plugin itself.</comment>

<file context>
@@ -1 +1,47 @@
+
+```bash
+pip install gql
+```
+
+### Registration
</file context>
Suggested change
```
pip install utcp-gql

✅ Addressed in 70ff230


if tool_call_template.use_tls:
creds = grpc.ssl_channel_credentials()
channel = aio.secure_channel(target, creds)
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Resource leak: gRPC channel is created but never closed. Consider using async with context manager or explicitly calling await channel.close() after the RPC call completes to prevent connection leaks.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py, line 103:

<comment>Resource leak: gRPC channel is created but never closed. Consider using `async with` context manager or explicitly calling `await channel.close()` after the RPC call completes to prevent connection leaks.</comment>

<file context>
@@ -0,0 +1,245 @@
+
+        if tool_call_template.use_tls:
+            creds = grpc.ssl_channel_credentials()
+            channel = aio.secure_channel(target, creds)
+        else:
+            channel = aio.insecure_channel(target)
</file context>

✅ Addressed in 70ff230

tool_args: Dict[str, Any],
tool_call_template: CallTemplate,
) -> AsyncGenerator[Any, None]:
# Basic implementation: execute non-streaming and yield once
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [gpt-5.2] call_tool_streaming currently isn’t truly streaming (it yields once). At minimum, fail fast for subscription operation_type or implement real GraphQL subscription streaming.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py, line 210:

<comment>[gpt-5.2] call_tool_streaming currently isn’t truly streaming (it yields once). At minimum, fail fast for subscription operation_type or implement real GraphQL subscription streaming.</comment>

<file context>
@@ -39,98 +58,158 @@ async def _handle_oauth2(self, auth: OAuth2Auth) -&gt; str:
+        tool_args: Dict[str, Any],
+        tool_call_template: CallTemplate,
+    ) -&gt; AsyncGenerator[Any, None]:
+        # Basic implementation: execute non-streaming and yield once
+        result = await self.call_tool(caller, tool_name, tool_args, tool_call_template)
+        yield result
</file context>

✅ Addressed in 70ff230

Comment on lines 193 to 196
arg_str = ", ".join(f"${k}: String" for k in filtered_args.keys())
var_defs = f"({arg_str})" if arg_str else ""
arg_pass = ', '.join(f"{k}: ${k}" for k in tool_args.keys())
arg_pass = ", ".join(f"{k}: ${k}" for k in filtered_args.keys())
arg_pass = f"({arg_pass})" if arg_pass else ""
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: [gpt-5.2] Declaring every variable as String can break calls for non-String argument types (ID/Int/Boolean/input objects). Infer variable types from the fetched schema (or otherwise avoid hard-coding String).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gql/src/utcp_gql/gql_communication_protocol.py, line 193:

<comment>[gpt-5.2] Declaring every variable as `String` can break calls for non-String argument types (ID/Int/Boolean/input objects). Infer variable types from the fetched schema (or otherwise avoid hard-coding String).</comment>

<file context>
@@ -39,98 +58,158 @@ async def _handle_oauth2(self, auth: OAuth2Auth) -&gt; str:
+            header_fields = tool_call_template.header_fields or []
+            filtered_args = {k: v for k, v in tool_args.items() if k not in header_fields}
+
+            arg_str = &quot;, &quot;.join(f&quot;${k}: String&quot; for k in filtered_args.keys())
             var_defs = f&quot;({arg_str})&quot; if arg_str else &quot;&quot;
-            arg_pass = &#39;, &#39;.join(f&quot;{k}: ${k}&quot; for k in tool_args.keys())
</file context>
Suggested change
arg_str = ", ".join(f"${k}: String" for k in filtered_args.keys())
var_defs = f"({arg_str})" if arg_str else ""
arg_pass = ', '.join(f"{k}: ${k}" for k in tool_args.keys())
arg_pass = ", ".join(f"{k}: ${k}" for k in filtered_args.keys())
arg_pass = f"({arg_pass})" if arg_pass else ""
schema = session.client.schema
root_type = {
"query": schema.query_type,
"mutation": schema.mutation_type,
"subscription": schema.subscription_type,
}.get(op_type)
if root_type is None or base_tool_name not in root_type.fields:
raise ValueError(f"Tool '{base_tool_name}' not found on GraphQL {op_type} root type")
field_def = root_type.fields[base_tool_name]
var_def_parts = []
for k in filtered_args.keys():
gql_type = str(field_def.args[k].type) if k in field_def.args else "String"
var_def_parts.append(f"${k}: {gql_type}")
var_defs = f"({', '.join(var_def_parts)})" if var_def_parts else ""
arg_pass_parts = [f"{k}: ${k}" for k in filtered_args.keys()]
arg_pass = f"({', '.join(arg_pass_parts)})" if arg_pass_parts else ""

✅ Addressed in 70ff230

if manual_call_template.use_tls:
pass
else:
if not (target.startswith("localhost") or target.startswith("127.0.0.1")):
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: [gpt-5.2] The localhost check is bypassable via startswith(...) (e.g., localhost.evil.com). Parse the hostname and compare exact loopback hosts before allowing insecure channels.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py, line 23:

<comment>[gpt-5.2] The localhost check is bypassable via `startswith(...)` (e.g., `localhost.evil.com`). Parse the hostname and compare exact loopback hosts before allowing insecure channels.</comment>

<file context>
@@ -0,0 +1,245 @@
+        if manual_call_template.use_tls:
+            pass
+        else:
+            if not (target.startswith(&quot;localhost&quot;) or target.startswith(&quot;127.0.0.1&quot;)):
+                return RegisterManualResult(
+                    success=False,
</file context>

✅ Addressed in 70ff230

req = getattr(msg_mod, "SubscribeRequest")()
sub_list = getattr(msg_mod, "SubscriptionList")()
mode = tool_args.get("mode", "stream").upper()
sub_list.mode = getattr(msg_mod, "SubscriptionList".upper(), None) or 0
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: [gpt-5.2] Subscribe mode is effectively ignored due to an incorrect getattr(...) target; this will likely always set mode to 0. Map the requested mode to the generated enum constant (with a safe default).

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py, line 145:

<comment>[gpt-5.2] Subscribe `mode` is effectively ignored due to an incorrect `getattr(...)` target; this will likely always set mode to 0. Map the requested mode to the generated enum constant (with a safe default).</comment>

<file context>
@@ -0,0 +1,245 @@
+            req = getattr(msg_mod, &quot;SubscribeRequest&quot;)()
+            sub_list = getattr(msg_mod, &quot;SubscriptionList&quot;)()
+            mode = tool_args.get(&quot;mode&quot;, &quot;stream&quot;).upper()
+            sub_list.mode = getattr(msg_mod, &quot;SubscriptionList&quot;.upper(), None) or 0
+            paths = tool_args.get(&quot;paths&quot;, [])
+            for p in paths:
</file context>

✅ Addressed in 70ff230

for elem in [e for e in str(upd.get("path", "")).strip("/").split("/") if e]:
pe = getattr(msg_mod, "PathElem")(name=elem)
path_msg.elem.append(pe)
val = getattr(msg_mod, "TypedValue")(json_val=str(upd.get("value", "")))
Copy link
Contributor

@cubic-dev-ai cubic-dev-ai bot Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: [gpt-5.2] TypedValue(json_val=...) is constructed with a Python string, which is not valid for gNMI JSON bytes fields and can break Set RPCs. Serialize values to real JSON bytes (prefer json_ietf_val when available) or fall back to string_val.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At plugins/communication_protocols/gnmi/src/utcp_gnmi/gnmi_communication_protocol.py, line 137:

<comment>[gpt-5.2] TypedValue(json_val=...) is constructed with a Python string, which is not valid for gNMI JSON bytes fields and can break Set RPCs. Serialize values to real JSON bytes (prefer json_ietf_val when available) or fall back to string_val.</comment>

<file context>
@@ -0,0 +1,245 @@
+                for elem in [e for e in str(upd.get(&quot;path&quot;, &quot;&quot;)).strip(&quot;/&quot;).split(&quot;/&quot;) if e]:
+                    pe = getattr(msg_mod, &quot;PathElem&quot;)(name=elem)
+                    path_msg.elem.append(pe)
+                val = getattr(msg_mod, &quot;TypedValue&quot;)(json_val=str(upd.get(&quot;value&quot;, &quot;&quot;)))
+                update_msg = getattr(msg_mod, &quot;Update&quot;)(path=path_msg, val=val)
+                req.update.append(update_msg)
</file context>

✅ Addressed in 70ff230

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 10 comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +14 to +16
class GnmiCommunicationProtocol(CommunicationProtocol):
def __init__(self):
self._oauth_tokens: Dict[str, Dict[str, Any]] = {}
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GnmiCommunicationProtocol class is missing a close() method. The GraphQL protocol implements this method (line 228-229 in gql_communication_protocol.py) to clean up OAuth tokens. For consistency and proper resource cleanup, the gNMI protocol should also implement this method.

Copilot uses AI. Check for mistakes.
Comment on lines 262 to 264
except Exception:
from aiohttp import BasicAuth as AiohttpBasicAuth
header_auth = AiohttpBasicAuth(auth_details.client_id, auth_details.client_secret)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bare except Exception: clause silently catches all exceptions from the first OAuth2 attempt and tries an alternative authentication method. This could mask programming errors or configuration issues. Consider catching specific exceptions (e.g., aiohttp.ClientResponseError) and logging the failure before attempting the fallback method. This will help with debugging when the primary authentication method fails.

Copilot uses AI. Check for mistakes.
Comment on lines 17 to 23
def _load_gnmi_modules(self, tool_call_template: GnmiCallTemplate):
grpc = importlib.import_module("grpc")
aio = importlib.import_module("grpc.aio")
json_format = importlib.import_module("google.protobuf.json_format")
stub_mod = importlib.import_module(tool_call_template.stub_module)
msg_mod = importlib.import_module(tool_call_template.message_module)
return grpc, aio, json_format, stub_mod, msg_mod
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _load_gnmi_modules method lacks return type annotations. Consider adding the return type annotation for better type safety and IDE support. For example: -> tuple[Any, Any, Any, Any, Any] or more specifically defining the types of grpc, aio, json_format, stub_mod, and msg_mod.

Copilot uses AI. Check for mistakes.
Comment on lines 24 to 38
def _create_grpc_channel(self, grpc, aio, target: str, use_tls: bool):
if use_tls:
creds = grpc.ssl_channel_credentials()
return aio.secure_channel(target, creds)
return aio.insecure_channel(target)
def _create_grpc_stub(self, stub_mod, channel):
stub = None
for attr in dir(stub_mod):
if attr.endswith("Stub"):
stub_cls = getattr(stub_mod, attr)
stub = stub_cls(channel)
break
if stub is None:
raise ValueError("gNMI stub not found in stub_module")
return stub
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The _create_grpc_channel and _create_grpc_stub methods lack return type annotations. Adding type hints would improve code clarity and enable better static analysis.

Copilot uses AI. Check for mistakes.
Comment on lines 102 to 126
async def call_tool(self, caller, tool_name: str, tool_args: Dict[str, Any], tool_call_template: CallTemplate) -> Any:
if not isinstance(tool_call_template, GnmiCallTemplate):
raise ValueError("GnmiCommunicationProtocol can only be used with GnmiCallTemplate")

op = tool_call_template.operation
target = tool_call_template.target

metadata: List[tuple[str, str]] = []
if tool_call_template.metadata:
metadata.extend([(k, v) for k, v in tool_call_template.metadata.items()])
if tool_call_template.metadata_fields:
for k in tool_call_template.metadata_fields:
if k in tool_args:
metadata.append((k, str(tool_args[k])))
if tool_call_template.auth:
if isinstance(tool_call_template.auth, ApiKeyAuth):
if tool_call_template.auth.api_key:
metadata.append((tool_call_template.auth.var_name or "authorization", tool_call_template.auth.api_key))
elif isinstance(tool_call_template.auth, BasicAuth):
import base64
token = base64.b64encode(f"{tool_call_template.auth.username}:{tool_call_template.auth.password}".encode()).decode()
metadata.append(("authorization", f"Basic {token}"))
elif isinstance(tool_call_template.auth, OAuth2Auth):
token = await self._handle_oauth2(tool_call_template.auth)
metadata.append(("authorization", f"Bearer {token}"))
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The authentication metadata building logic is duplicated between call_tool (lines 109-126) and call_tool_streaming (lines 197-214). Consider extracting this into a shared helper method like _build_metadata(tool_call_template, tool_args) to reduce code duplication and improve maintainability. This pattern is already used in the GraphQL protocol with _prepare_headers().

Copilot uses AI. Check for mistakes.
Comment on lines +177 to +179
raise ValueError("Unsupported gNMI operation")
else:
raise ValueError("Unsupported gNMI operation")
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error message "Unsupported gNMI operation" is generic and doesn't indicate what operation was provided. Consider including the actual operation value in the error message for easier debugging, e.g., f"Unsupported gNMI operation: {op}".

Suggested change
raise ValueError("Unsupported gNMI operation")
else:
raise ValueError("Unsupported gNMI operation")
raise ValueError(f"Unsupported gNMI operation: {op}")
else:
raise ValueError(f"Unsupported gNMI operation: {op}")

Copilot uses AI. Check for mistakes.
Comment on lines 102 to 105
except (UtcpSerializerValidationError, ValueError) as e:
# Fallback to manual template if validation fails, but log details
logger.exception("Failed to validate existing tool_call_template; falling back to manual template")
normalized["tool_call_template"] = manual_call_template
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While catching specific exception types (UtcpSerializerValidationError, ValueError) is better than a bare except Exception, you should not suppress the variable e in the except clause. The caught exception is logged via logger.exception() which will include the traceback, but explicitly using the exception variable would be more clear. Also, the except clause on line 112 is inconsistent - it only catches UtcpSerializerValidationError, not ValueError. Consider standardizing the exception handling across both branches.

Copilot uses AI. Check for mistakes.
Comment on lines +8 to +16
class GnmiCallTemplate(CallTemplate):
call_template_type: Literal["gnmi"] = "gnmi"
target: str
use_tls: bool = True
metadata: Optional[Dict[str, str]] = None
metadata_fields: Optional[List[str]] = None
operation: Literal["capabilities", "get", "set", "subscribe"] = "get"
stub_module: str = "gnmi_pb2_grpc"
message_module: str = "gnmi_pb2"
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GnmiCallTemplate class is missing auth serialization/validation similar to what GraphQLProvider has. GraphQLProvider includes @field_serializer("auth") and @field_validator("auth", mode="before") decorators (lines 34-47 in gql_call_template.py) to properly handle Auth object serialization and deserialization. Without these, the auth field may not serialize/deserialize correctly when going through the CallTemplate lifecycle.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot apply changes based on this feedback to the current PR

Comment on lines +14 to +31
@pytest.mark.asyncio
async def test_register_manual_and_tools():
register()
client = await UtcpClient.create(config={
"manual_call_templates": [
{
"name": "routerA",
"call_template_type": "gnmi",
"target": "localhost:50051",
"use_tls": False,
"operation": "get"
}
]
})
tools = await client.config.tool_repository.get_tools()
names = [t.name for t in tools]
assert any(n.startswith("routerA.") for n in names)
assert any(n.endswith("subscribe") for n in names)
Copy link

Copilot AI Dec 14, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test suite lacks coverage for the core call_tool and call_tool_streaming methods in GnmiCommunicationProtocol. While test_register_manual_and_tools validates tool registration, there are no tests for actual RPC calls (capabilities, get, set) or streaming subscribe. The GraphQL plugin includes comprehensive tests that call tools (lines 103-110 in test_graphql_protocol.py). Consider adding similar tests with mocked gRPC stubs to verify the request building and response handling logic.

Copilot uses AI. Check for mistakes.
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot l request to apply changes based on this feedback to the current PR

@Thuraabtech
Copy link
Contributor Author

@h3xxit review these changes and let me know if there is something to update.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants