stinger-python-utils 0.1.6__tar.gz → 0.1.8rc1__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.
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/PKG-INFO +1 -1
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/pyproject.toml +1 -1
- stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/__init__.py +20 -0
- stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/__main__.py +56 -0
- stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/plugin.py +214 -0
- stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/server.py +557 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/message_creator.py +7 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/test/test_message_creator.py +13 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.github/workflows/python-tests.yml +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.github/workflows/python37.yml +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.gitignore +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.python-version +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.vscode/settings.json +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/LICENSE +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/README.md +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/__init__.py +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/return_codes.py +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/test/__init__.py +0 -0
- {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/uv.lock +0 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Stinger MCP server – plugin-based MCP interface to stinger-ipc services.
|
|
2
|
+
|
|
3
|
+
Public API re-exported here for convenience::
|
|
4
|
+
|
|
5
|
+
from stinger_python_utils.mcp import StingerMCPPlugin, SignalDefinition, ...
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .plugin import (
|
|
9
|
+
MethodDefinition,
|
|
10
|
+
PropertyDefinition,
|
|
11
|
+
SignalDefinition,
|
|
12
|
+
StingerMCPPlugin,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"MethodDefinition",
|
|
17
|
+
"PropertyDefinition",
|
|
18
|
+
"SignalDefinition",
|
|
19
|
+
"StingerMCPPlugin",
|
|
20
|
+
]
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Entry-point for ``python -m stinger_python_utils.mcp``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import logging
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def main() -> None:
|
|
11
|
+
parser = argparse.ArgumentParser(
|
|
12
|
+
prog="stinger-mcp-server",
|
|
13
|
+
description="Stinger MCP Server – expose stinger-ipc services over MCP",
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument(
|
|
16
|
+
"--transport",
|
|
17
|
+
choices=["stdio", "sse"],
|
|
18
|
+
default="stdio",
|
|
19
|
+
help="MCP transport to use (default: stdio)",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--host",
|
|
23
|
+
default="0.0.0.0",
|
|
24
|
+
help="Bind address for SSE transport (default: 0.0.0.0)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--port",
|
|
28
|
+
type=int,
|
|
29
|
+
default=8000,
|
|
30
|
+
help="Port for SSE transport (default: 8000)",
|
|
31
|
+
)
|
|
32
|
+
parser.add_argument(
|
|
33
|
+
"--log-level",
|
|
34
|
+
default="INFO",
|
|
35
|
+
choices=["DEBUG", "INFO", "WARNING", "ERROR"],
|
|
36
|
+
help="Logging level (default: INFO)",
|
|
37
|
+
)
|
|
38
|
+
args = parser.parse_args()
|
|
39
|
+
|
|
40
|
+
logging.basicConfig(
|
|
41
|
+
level=getattr(logging, args.log_level),
|
|
42
|
+
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
from .server import StingerMCPServer
|
|
46
|
+
|
|
47
|
+
server = StingerMCPServer()
|
|
48
|
+
|
|
49
|
+
if args.transport == "stdio":
|
|
50
|
+
asyncio.run(server.run_stdio())
|
|
51
|
+
elif args.transport == "sse":
|
|
52
|
+
asyncio.run(server.run_sse(host=args.host, port=args.port))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
if __name__ == "__main__":
|
|
56
|
+
main()
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""ABC interface and data models for stinger MCP plugins.
|
|
2
|
+
|
|
3
|
+
Third-party packages implement :class:`StingerMCPPlugin` and register it
|
|
4
|
+
as a stevedore entry-point under the
|
|
5
|
+
``stinger_python_utils.mcp_plugins`` namespace.
|
|
6
|
+
|
|
7
|
+
Example ``pyproject.toml`` of a *plugin* package::
|
|
8
|
+
|
|
9
|
+
[project.entry-points."stinger_python_utils.mcp_plugins"]
|
|
10
|
+
my_service = "my_package.mcp_plugin:MyServicePlugin"
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
from abc import ABC, abstractmethod
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
# ------------------------------------------------------------------
|
|
22
|
+
# Data models
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class SignalDefinition:
|
|
28
|
+
"""Describes a signal emitted by a stinger-ipc client.
|
|
29
|
+
|
|
30
|
+
The MCP server calls ``client.receive_{name}(callback)`` and stores
|
|
31
|
+
received payloads in a per-instance mailbox exposed as an MCP
|
|
32
|
+
resource.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
name: str
|
|
36
|
+
description: str = ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class PropertyDefinition:
|
|
41
|
+
"""Describes a property on a stinger-ipc client.
|
|
42
|
+
|
|
43
|
+
Every property is exposed as an MCP **resource**. Writable
|
|
44
|
+
properties (``readonly=False``) additionally get an MCP **tool**
|
|
45
|
+
whose ``inputSchema`` is *schema*.
|
|
46
|
+
|
|
47
|
+
*schema* must be a valid `JSON Schema`_ object.
|
|
48
|
+
|
|
49
|
+
.. _JSON Schema: https://json-schema.org/
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
name: str
|
|
53
|
+
schema: dict[str, Any] = field(
|
|
54
|
+
default_factory=lambda: {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"properties": {"value": {}},
|
|
57
|
+
"required": ["value"],
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
readonly: bool = True
|
|
61
|
+
description: str = ""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclass(frozen=True)
|
|
65
|
+
class MethodDefinition:
|
|
66
|
+
"""Describes a callable method on a stinger-ipc client.
|
|
67
|
+
|
|
68
|
+
Each method is exposed as an MCP **tool** whose ``inputSchema`` is
|
|
69
|
+
*arguments_schema*.
|
|
70
|
+
|
|
71
|
+
*arguments_schema* must be a valid `JSON Schema`_ of type
|
|
72
|
+
``"object"``. The property names **must** match the keyword
|
|
73
|
+
argument names accepted by the client method.
|
|
74
|
+
|
|
75
|
+
.. _JSON Schema: https://json-schema.org/
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
name: str
|
|
79
|
+
arguments_schema: dict[str, Any] = field(
|
|
80
|
+
default_factory=lambda: {"type": "object"}
|
|
81
|
+
)
|
|
82
|
+
description: str = ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
# Plugin ABC
|
|
87
|
+
# ------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class StingerMCPPlugin(ABC):
|
|
91
|
+
"""ABC that stevedore plugins must implement.
|
|
92
|
+
|
|
93
|
+
Register concrete subclasses as entry-points under the namespace
|
|
94
|
+
``stinger_python_utils.mcp_plugins``::
|
|
95
|
+
|
|
96
|
+
# pyproject.toml of the *plugin* package
|
|
97
|
+
[project.entry-points."stinger_python_utils.mcp_plugins"]
|
|
98
|
+
my_service = "my_package.plugin:MyPlugin"
|
|
99
|
+
|
|
100
|
+
The MCP server loads all registered plugins at startup and:
|
|
101
|
+
|
|
102
|
+
1. Instantiates the **discoverer** (from :meth:`get_discovery_class`)
|
|
103
|
+
with a shared ``pyqttier`` ``IBrokerConnection``.
|
|
104
|
+
2. On each discovered instance, instantiates the **client** (from
|
|
105
|
+
:meth:`get_client_class`) with ``(connection, discovered_instance)``.
|
|
106
|
+
3. Wires signals, properties, and methods into the MCP protocol.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
# ------------------------------------------------------------------
|
|
110
|
+
# Required – every plugin must implement these
|
|
111
|
+
# ------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
@abstractmethod
|
|
114
|
+
def get_plugin_name(self) -> str:
|
|
115
|
+
"""Return a unique, short identifier for this plugin.
|
|
116
|
+
|
|
117
|
+
Used as the scheme/prefix in MCP resource URIs and tool names
|
|
118
|
+
(e.g. ``"lights"`` → ``lights://instance123/property/brightness``).
|
|
119
|
+
"""
|
|
120
|
+
...
|
|
121
|
+
|
|
122
|
+
@abstractmethod
|
|
123
|
+
def get_discovery_class(self) -> type:
|
|
124
|
+
"""Return the *Discoverer* class for this service type.
|
|
125
|
+
|
|
126
|
+
The MCP server instantiates it as::
|
|
127
|
+
|
|
128
|
+
discoverer = DiscovererClass(connection)
|
|
129
|
+
|
|
130
|
+
The class **must** expose:
|
|
131
|
+
|
|
132
|
+
* ``add_discovered_service_callback(cb)`` – *cb* receives a
|
|
133
|
+
``DiscoveredInstance`` (opaque pydantic model with at minimum
|
|
134
|
+
an ``instance_id: str`` attribute).
|
|
135
|
+
* ``add_removed_service_callback(cb)`` – *cb* receives the
|
|
136
|
+
``instance_id: str`` of the departed instance.
|
|
137
|
+
"""
|
|
138
|
+
...
|
|
139
|
+
|
|
140
|
+
@abstractmethod
|
|
141
|
+
def get_client_class(self) -> type:
|
|
142
|
+
"""Return the *Client* class for this service type.
|
|
143
|
+
|
|
144
|
+
The MCP server instantiates it as::
|
|
145
|
+
|
|
146
|
+
client = ClientClass(connection, discovered_instance)
|
|
147
|
+
"""
|
|
148
|
+
...
|
|
149
|
+
|
|
150
|
+
@abstractmethod
|
|
151
|
+
def get_signals(self) -> list[SignalDefinition]:
|
|
152
|
+
"""Return the list of signals the client can emit."""
|
|
153
|
+
...
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def get_properties(self) -> list[PropertyDefinition]:
|
|
157
|
+
"""Return the list of properties the client exposes."""
|
|
158
|
+
...
|
|
159
|
+
|
|
160
|
+
@abstractmethod
|
|
161
|
+
def get_methods(self) -> list[MethodDefinition]:
|
|
162
|
+
"""Return the list of callable methods the client exposes."""
|
|
163
|
+
...
|
|
164
|
+
|
|
165
|
+
# ------------------------------------------------------------------
|
|
166
|
+
# Defaults – override for non-standard behaviour
|
|
167
|
+
# ------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def read_property(self, client: Any, prop_name: str) -> Any:
|
|
170
|
+
"""Read a property value from *client*.
|
|
171
|
+
|
|
172
|
+
The default implementation returns ``getattr(client, prop_name)``.
|
|
173
|
+
"""
|
|
174
|
+
return getattr(client, prop_name)
|
|
175
|
+
|
|
176
|
+
def write_property(
|
|
177
|
+
self, client: Any, prop_name: str, arguments: dict[str, Any]
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Set a property on *client* from MCP tool *arguments*.
|
|
180
|
+
|
|
181
|
+
*arguments* is the dict parsed from the tool's JSON Schema
|
|
182
|
+
input. The default implementation does::
|
|
183
|
+
|
|
184
|
+
setattr(client, prop_name, arguments["value"])
|
|
185
|
+
|
|
186
|
+
Override when the property value is a composite type that must
|
|
187
|
+
be reconstructed from several arguments.
|
|
188
|
+
"""
|
|
189
|
+
setattr(client, prop_name, arguments["value"])
|
|
190
|
+
|
|
191
|
+
def call_method(
|
|
192
|
+
self, client: Any, method_name: str, arguments: dict[str, Any]
|
|
193
|
+
) -> Any:
|
|
194
|
+
"""Invoke *method_name* on *client* with *arguments*.
|
|
195
|
+
|
|
196
|
+
The default implementation calls::
|
|
197
|
+
|
|
198
|
+
getattr(client, method_name)(**arguments)
|
|
199
|
+
|
|
200
|
+
and returns whatever the method returns (typically a
|
|
201
|
+
``concurrent.futures.Future``).
|
|
202
|
+
"""
|
|
203
|
+
method = getattr(client, f"call_{method_name}")
|
|
204
|
+
return method(**arguments)
|
|
205
|
+
|
|
206
|
+
def serialize_property(self, prop_name: str, value: Any) -> str:
|
|
207
|
+
"""Serialize a property *value* to a JSON string for the MCP resource.
|
|
208
|
+
|
|
209
|
+
The default implementation handles pydantic ``BaseModel``
|
|
210
|
+
instances and falls back to :func:`json.dumps`.
|
|
211
|
+
"""
|
|
212
|
+
if hasattr(value, "model_dump_json"):
|
|
213
|
+
return value.model_dump_json()
|
|
214
|
+
return json.dumps(value, default=str)
|
|
@@ -0,0 +1,557 @@
|
|
|
1
|
+
"""MCP server that dynamically exposes stinger-ipc services via plugins.
|
|
2
|
+
|
|
3
|
+
Plugins are discovered through stevedore entry-points registered under
|
|
4
|
+
the ``stinger_python_utils.mcp_plugins`` namespace. Each plugin
|
|
5
|
+
supplies a discoverer, a client class, and metadata describing signals,
|
|
6
|
+
properties, and methods. As service instances appear and disappear on
|
|
7
|
+
the MQTT bus the MCP tool and resource lists are updated accordingly.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import threading
|
|
18
|
+
import uuid
|
|
19
|
+
from collections import deque
|
|
20
|
+
from concurrent.futures import Future
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from datetime import datetime, timezone
|
|
23
|
+
from typing import Any
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
import mcp.server.stdio
|
|
27
|
+
import mcp.types as types
|
|
28
|
+
from mcp.server.lowlevel import NotificationOptions, Server
|
|
29
|
+
from mcp.server.models import InitializationOptions
|
|
30
|
+
from pydantic import AnyUrl
|
|
31
|
+
from pyqttier.connection import Mqtt5Connection
|
|
32
|
+
from pyqttier.transport import MqttTransport, MqttTransportType
|
|
33
|
+
from stevedore import ExtensionManager
|
|
34
|
+
|
|
35
|
+
from .plugin import (
|
|
36
|
+
MethodDefinition,
|
|
37
|
+
PropertyDefinition,
|
|
38
|
+
SignalDefinition,
|
|
39
|
+
StingerMCPPlugin,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
44
|
+
# ------------------------------------------------------------------
|
|
45
|
+
# Internal data structures
|
|
46
|
+
# ------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
SIGNAL_MAILBOX_SIZE = 10
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class SignalMailbox:
|
|
53
|
+
"""Bounded FIFO of the most recent signal payloads."""
|
|
54
|
+
|
|
55
|
+
_entries: deque[dict[str, Any]] = field(
|
|
56
|
+
default_factory=lambda: deque(maxlen=SIGNAL_MAILBOX_SIZE)
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
def append(self, data: dict[str, Any]) -> None:
|
|
60
|
+
self._entries.append(
|
|
61
|
+
{
|
|
62
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
63
|
+
"data": data,
|
|
64
|
+
}
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
def to_json(self) -> str:
|
|
68
|
+
return json.dumps(list(self._entries), default=str)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class InstanceState:
|
|
73
|
+
"""Run-time bookkeeping for one discovered service instance."""
|
|
74
|
+
|
|
75
|
+
plugin_name: str
|
|
76
|
+
plugin: StingerMCPPlugin
|
|
77
|
+
instance_id: str
|
|
78
|
+
client: Any
|
|
79
|
+
signal_mailboxes: dict[str, SignalMailbox] = field(default_factory=dict)
|
|
80
|
+
property_cache: dict[str, Any] = field(default_factory=dict)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
# Helpers
|
|
85
|
+
# ------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _sanitize(value: str) -> str:
|
|
89
|
+
"""Turn an arbitrary string into a safe MCP identifier fragment."""
|
|
90
|
+
return re.sub(r"[^A-Za-z0-9_]", "_", value)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _instance_key(plugin_name: str, instance_id: str) -> str:
|
|
94
|
+
return f"{_sanitize(plugin_name)}_{_sanitize(instance_id)}"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
async def _resolve_future(future: Future[Any], timeout: float = 30.0) -> Any:
|
|
98
|
+
"""Bridge a :class:`concurrent.futures.Future` into *asyncio*."""
|
|
99
|
+
return await asyncio.wait_for(asyncio.wrap_future(future), timeout=timeout)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# MQTT connection factory
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def create_mqtt_connection() -> Mqtt5Connection:
|
|
108
|
+
"""Create an :class:`Mqtt5Connection` from environment variables.
|
|
109
|
+
|
|
110
|
+
================== =========== ===============================
|
|
111
|
+
Variable Default Description
|
|
112
|
+
================== =========== ===============================
|
|
113
|
+
``MQTT_HOST`` localhost Broker hostname
|
|
114
|
+
``MQTT_PORT`` 1883 Broker port
|
|
115
|
+
``MQTT_TRANSPORT`` tcp ``tcp`` | ``websocket`` | ``unix``
|
|
116
|
+
``MQTT_CLIENT_ID`` (random) MQTT client identifier
|
|
117
|
+
================== =========== ===============================
|
|
118
|
+
"""
|
|
119
|
+
host = os.environ.get("MQTT_HOST", "localhost")
|
|
120
|
+
port = int(os.environ.get("MQTT_PORT", "1883"))
|
|
121
|
+
transport_str = os.environ.get("MQTT_TRANSPORT", "tcp").upper()
|
|
122
|
+
transport_type = getattr(MqttTransportType, transport_str, MqttTransportType.TCP)
|
|
123
|
+
client_id = os.environ.get(
|
|
124
|
+
"MQTT_CLIENT_ID", f"stinger-mcp-{uuid.uuid4().hex[:8]}"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
transport = MqttTransport(transport_type, host=host, port=port)
|
|
128
|
+
return Mqtt5Connection(transport=transport, client_id=client_id)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
# ------------------------------------------------------------------
|
|
132
|
+
# The server
|
|
133
|
+
# ------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class StingerMCPServer:
|
|
137
|
+
"""Loads stevedore plugins and serves them over MCP."""
|
|
138
|
+
|
|
139
|
+
STEVEDORE_NAMESPACE = "stinger_python_utils.mcp_plugins"
|
|
140
|
+
|
|
141
|
+
def __init__(self) -> None:
|
|
142
|
+
self._server = Server("stinger-mcp")
|
|
143
|
+
self._plugins: list[StingerMCPPlugin] = []
|
|
144
|
+
self._instances: dict[str, InstanceState] = {}
|
|
145
|
+
self._lock = threading.Lock()
|
|
146
|
+
self._loop: asyncio.AbstractEventLoop | None = None
|
|
147
|
+
self._connection: Mqtt5Connection | None = None
|
|
148
|
+
|
|
149
|
+
self._register_handlers()
|
|
150
|
+
|
|
151
|
+
# ==================================================================
|
|
152
|
+
# MCP handler registration
|
|
153
|
+
# ==================================================================
|
|
154
|
+
|
|
155
|
+
def _register_handlers(self) -> None:
|
|
156
|
+
server = self._server
|
|
157
|
+
|
|
158
|
+
@server.list_tools()
|
|
159
|
+
async def handle_list_tools() -> list[types.Tool]:
|
|
160
|
+
return self._build_tool_list()
|
|
161
|
+
|
|
162
|
+
@server.call_tool()
|
|
163
|
+
async def handle_call_tool(
|
|
164
|
+
name: str, arguments: dict[str, Any] | None
|
|
165
|
+
) -> list[types.TextContent]:
|
|
166
|
+
return await self._dispatch_tool(name, arguments or {})
|
|
167
|
+
|
|
168
|
+
@server.list_resources()
|
|
169
|
+
async def handle_list_resources() -> list[types.Resource]:
|
|
170
|
+
return self._build_resource_list()
|
|
171
|
+
|
|
172
|
+
@server.read_resource()
|
|
173
|
+
async def handle_read_resource(uri: AnyUrl) -> str:
|
|
174
|
+
return self._read_resource(uri)
|
|
175
|
+
|
|
176
|
+
# ==================================================================
|
|
177
|
+
# Tools
|
|
178
|
+
# ==================================================================
|
|
179
|
+
|
|
180
|
+
def _build_tool_list(self) -> list[types.Tool]:
|
|
181
|
+
tools: list[types.Tool] = []
|
|
182
|
+
with self._lock:
|
|
183
|
+
for state in self._instances.values():
|
|
184
|
+
pn = _sanitize(state.plugin_name)
|
|
185
|
+
iid = _sanitize(state.instance_id)
|
|
186
|
+
|
|
187
|
+
for mdef in state.plugin.get_methods():
|
|
188
|
+
tools.append(
|
|
189
|
+
types.Tool(
|
|
190
|
+
name=f"{pn}_{iid}_{_sanitize(mdef.name)}",
|
|
191
|
+
description=(
|
|
192
|
+
mdef.description
|
|
193
|
+
or f"Call {mdef.name} on {state.plugin_name} "
|
|
194
|
+
f"instance {state.instance_id}"
|
|
195
|
+
),
|
|
196
|
+
inputSchema=mdef.arguments_schema,
|
|
197
|
+
)
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
for pdef in state.plugin.get_properties():
|
|
201
|
+
if not pdef.readonly:
|
|
202
|
+
tools.append(
|
|
203
|
+
types.Tool(
|
|
204
|
+
name=f"{pn}_{iid}_set_{_sanitize(pdef.name)}",
|
|
205
|
+
description=(
|
|
206
|
+
pdef.description
|
|
207
|
+
or f"Set {pdef.name} on {state.plugin_name} "
|
|
208
|
+
f"instance {state.instance_id}"
|
|
209
|
+
),
|
|
210
|
+
inputSchema=pdef.schema,
|
|
211
|
+
)
|
|
212
|
+
)
|
|
213
|
+
return tools
|
|
214
|
+
|
|
215
|
+
def _resolve_tool(
|
|
216
|
+
self, name: str
|
|
217
|
+
) -> tuple[InstanceState, str, str] | None:
|
|
218
|
+
"""Map a tool *name* → ``(state, kind, item_name)``.
|
|
219
|
+
|
|
220
|
+
*kind* is ``"method"`` or ``"property"``. Methods are checked
|
|
221
|
+
first so that a method named ``set_foo`` takes precedence over
|
|
222
|
+
a property setter for ``foo``.
|
|
223
|
+
"""
|
|
224
|
+
with self._lock:
|
|
225
|
+
for state in self._instances.values():
|
|
226
|
+
pn = _sanitize(state.plugin_name)
|
|
227
|
+
iid = _sanitize(state.instance_id)
|
|
228
|
+
prefix = f"{pn}_{iid}_"
|
|
229
|
+
|
|
230
|
+
if not name.startswith(prefix):
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
remainder = name[len(prefix) :]
|
|
234
|
+
|
|
235
|
+
# Methods take priority
|
|
236
|
+
for mdef in state.plugin.get_methods():
|
|
237
|
+
if _sanitize(mdef.name) == remainder:
|
|
238
|
+
return state, "method", mdef.name
|
|
239
|
+
|
|
240
|
+
# Property setters: set_<prop_name>
|
|
241
|
+
if remainder.startswith("set_"):
|
|
242
|
+
prop_token = remainder[4:]
|
|
243
|
+
for pdef in state.plugin.get_properties():
|
|
244
|
+
if (
|
|
245
|
+
_sanitize(pdef.name) == prop_token
|
|
246
|
+
and not pdef.readonly
|
|
247
|
+
):
|
|
248
|
+
return state, "property", pdef.name
|
|
249
|
+
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
async def _dispatch_tool(
|
|
253
|
+
self, name: str, arguments: dict[str, Any]
|
|
254
|
+
) -> list[types.TextContent]:
|
|
255
|
+
target = self._resolve_tool(name)
|
|
256
|
+
if target is None:
|
|
257
|
+
raise ValueError(f"Unknown tool: {name}")
|
|
258
|
+
|
|
259
|
+
state, kind, item_name = target
|
|
260
|
+
|
|
261
|
+
if kind == "property":
|
|
262
|
+
try:
|
|
263
|
+
state.plugin.write_property(state.client, item_name, arguments)
|
|
264
|
+
text = json.dumps({"status": "ok", "property": item_name})
|
|
265
|
+
except Exception as exc:
|
|
266
|
+
logger.exception("Error setting property %s", item_name)
|
|
267
|
+
text = json.dumps(
|
|
268
|
+
{"status": "error", "error": str(exc)}, default=str
|
|
269
|
+
)
|
|
270
|
+
return [types.TextContent(type="text", text=text)]
|
|
271
|
+
|
|
272
|
+
# kind == "method"
|
|
273
|
+
try:
|
|
274
|
+
result = state.plugin.call_method(
|
|
275
|
+
state.client, item_name, arguments
|
|
276
|
+
)
|
|
277
|
+
if isinstance(result, Future):
|
|
278
|
+
result = await _resolve_future(result)
|
|
279
|
+
|
|
280
|
+
if hasattr(result, "model_dump_json"):
|
|
281
|
+
text = result.model_dump_json()
|
|
282
|
+
elif result is None:
|
|
283
|
+
text = json.dumps({"status": "ok"})
|
|
284
|
+
else:
|
|
285
|
+
text = json.dumps(result, default=str)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
logger.exception("Error calling method %s", item_name)
|
|
288
|
+
text = json.dumps(
|
|
289
|
+
{"status": "error", "error": str(exc)}, default=str
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
return [types.TextContent(type="text", text=text)]
|
|
293
|
+
|
|
294
|
+
# ==================================================================
|
|
295
|
+
# Resources
|
|
296
|
+
# ==================================================================
|
|
297
|
+
|
|
298
|
+
def _build_resource_list(self) -> list[types.Resource]:
|
|
299
|
+
resources: list[types.Resource] = []
|
|
300
|
+
with self._lock:
|
|
301
|
+
for state in self._instances.values():
|
|
302
|
+
pn = state.plugin_name
|
|
303
|
+
iid = state.instance_id
|
|
304
|
+
|
|
305
|
+
for pdef in state.plugin.get_properties():
|
|
306
|
+
resources.append(
|
|
307
|
+
types.Resource(
|
|
308
|
+
uri=AnyUrl(f"{pn}://{iid}/property/{pdef.name}"),
|
|
309
|
+
name=f"{pn} {iid} – {pdef.name}",
|
|
310
|
+
description=pdef.description
|
|
311
|
+
or f"Property {pdef.name}",
|
|
312
|
+
mimeType="application/json",
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
for sdef in state.plugin.get_signals():
|
|
317
|
+
resources.append(
|
|
318
|
+
types.Resource(
|
|
319
|
+
uri=AnyUrl(f"{pn}://{iid}/signal/{sdef.name}"),
|
|
320
|
+
name=f"{pn} {iid} – {sdef.name} (signals)",
|
|
321
|
+
description=sdef.description
|
|
322
|
+
or f"Mailbox of the last {SIGNAL_MAILBOX_SIZE} "
|
|
323
|
+
f"'{sdef.name}' signals",
|
|
324
|
+
mimeType="application/json",
|
|
325
|
+
)
|
|
326
|
+
)
|
|
327
|
+
return resources
|
|
328
|
+
|
|
329
|
+
def _read_resource(self, uri: AnyUrl) -> str:
|
|
330
|
+
parsed = urlparse(str(uri))
|
|
331
|
+
plugin_name = parsed.scheme
|
|
332
|
+
instance_id = parsed.netloc
|
|
333
|
+
path_parts = [p for p in parsed.path.strip("/").split("/") if p]
|
|
334
|
+
|
|
335
|
+
if len(path_parts) < 2:
|
|
336
|
+
raise ValueError(f"Invalid resource URI: {uri}")
|
|
337
|
+
|
|
338
|
+
category, item_name = path_parts[0], path_parts[1]
|
|
339
|
+
key = _instance_key(plugin_name, instance_id)
|
|
340
|
+
|
|
341
|
+
with self._lock:
|
|
342
|
+
state = self._instances.get(key)
|
|
343
|
+
|
|
344
|
+
if state is None:
|
|
345
|
+
raise ValueError(
|
|
346
|
+
f"No active instance for {plugin_name}/{instance_id}"
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
if category == "property":
|
|
350
|
+
value = state.plugin.read_property(state.client, item_name)
|
|
351
|
+
return state.plugin.serialize_property(item_name, value)
|
|
352
|
+
|
|
353
|
+
if category == "signal":
|
|
354
|
+
mailbox = state.signal_mailboxes.get(item_name)
|
|
355
|
+
if mailbox is None:
|
|
356
|
+
return "[]"
|
|
357
|
+
return mailbox.to_json()
|
|
358
|
+
|
|
359
|
+
raise ValueError(f"Unknown resource category: {category}")
|
|
360
|
+
|
|
361
|
+
# ==================================================================
|
|
362
|
+
# Plugin loading & discovery wiring
|
|
363
|
+
# ==================================================================
|
|
364
|
+
|
|
365
|
+
def _load_plugins(self) -> None:
|
|
366
|
+
def _on_failure(
|
|
367
|
+
_mgr: ExtensionManager, entrypoint: Any, err: Exception
|
|
368
|
+
) -> None:
|
|
369
|
+
logger.error("Failed to load plugin %s: %s", entrypoint, err)
|
|
370
|
+
|
|
371
|
+
mgr = ExtensionManager(
|
|
372
|
+
namespace=self.STEVEDORE_NAMESPACE,
|
|
373
|
+
invoke_on_load=True,
|
|
374
|
+
on_load_failure_callback=_on_failure,
|
|
375
|
+
)
|
|
376
|
+
for ext in mgr:
|
|
377
|
+
plugin = ext.obj
|
|
378
|
+
if isinstance(plugin, StingerMCPPlugin):
|
|
379
|
+
self._plugins.append(plugin)
|
|
380
|
+
logger.info(
|
|
381
|
+
"Loaded MCP plugin: %s", plugin.get_plugin_name()
|
|
382
|
+
)
|
|
383
|
+
else:
|
|
384
|
+
logger.warning(
|
|
385
|
+
"Entry-point %s did not produce a StingerMCPPlugin "
|
|
386
|
+
"(got %s); skipping.",
|
|
387
|
+
ext.name,
|
|
388
|
+
type(plugin).__name__,
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
def _start_discovery(self) -> None:
|
|
392
|
+
assert self._connection is not None
|
|
393
|
+
for plugin in self._plugins:
|
|
394
|
+
discoverer_cls = plugin.get_discovery_class()
|
|
395
|
+
discoverer = discoverer_cls(self._connection)
|
|
396
|
+
|
|
397
|
+
discoverer.add_discovered_service_callback(
|
|
398
|
+
lambda inst, _p=plugin: self._on_discovered(inst, _p)
|
|
399
|
+
)
|
|
400
|
+
discoverer.add_removed_service_callback(
|
|
401
|
+
lambda iid, _p=plugin: self._on_removed(iid, _p)
|
|
402
|
+
)
|
|
403
|
+
logger.info(
|
|
404
|
+
"Discovery started for plugin '%s'",
|
|
405
|
+
plugin.get_plugin_name(),
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
# ---- callbacks (called on the MQTT thread) -----------------------
|
|
409
|
+
|
|
410
|
+
def _on_discovered(
|
|
411
|
+
self, discovered_instance: Any, plugin: StingerMCPPlugin
|
|
412
|
+
) -> None:
|
|
413
|
+
"""Register a newly-discovered service instance."""
|
|
414
|
+
plugin_name = plugin.get_plugin_name()
|
|
415
|
+
instance_id: str = discovered_instance.instance_id
|
|
416
|
+
key = _instance_key(plugin_name, instance_id)
|
|
417
|
+
|
|
418
|
+
with self._lock:
|
|
419
|
+
if key in self._instances:
|
|
420
|
+
logger.debug(
|
|
421
|
+
"Instance %s/%s already tracked; ignoring.",
|
|
422
|
+
plugin_name,
|
|
423
|
+
instance_id,
|
|
424
|
+
)
|
|
425
|
+
return
|
|
426
|
+
|
|
427
|
+
logger.info("Discovered %s / %s", plugin_name, instance_id)
|
|
428
|
+
|
|
429
|
+
client_cls = plugin.get_client_class()
|
|
430
|
+
client = client_cls(self._connection, discovered_instance)
|
|
431
|
+
|
|
432
|
+
state = InstanceState(
|
|
433
|
+
plugin_name=plugin_name,
|
|
434
|
+
plugin=plugin,
|
|
435
|
+
instance_id=instance_id,
|
|
436
|
+
client=client,
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
# -- signals ---------------------------------------------------
|
|
440
|
+
for sdef in plugin.get_signals():
|
|
441
|
+
mailbox = SignalMailbox()
|
|
442
|
+
state.signal_mailboxes[sdef.name] = mailbox
|
|
443
|
+
|
|
444
|
+
recv_fn = getattr(client, f"receive_{sdef.name}", None)
|
|
445
|
+
if recv_fn is not None:
|
|
446
|
+
|
|
447
|
+
def _make_signal_cb(mb: SignalMailbox) -> Any:
|
|
448
|
+
def _cb(**kwargs: Any) -> None:
|
|
449
|
+
mb.append(kwargs)
|
|
450
|
+
|
|
451
|
+
return _cb
|
|
452
|
+
|
|
453
|
+
recv_fn(_make_signal_cb(mailbox))
|
|
454
|
+
|
|
455
|
+
# -- properties ------------------------------------------------
|
|
456
|
+
for pdef in plugin.get_properties():
|
|
457
|
+
try:
|
|
458
|
+
state.property_cache[pdef.name] = plugin.read_property(
|
|
459
|
+
client, pdef.name
|
|
460
|
+
)
|
|
461
|
+
except Exception:
|
|
462
|
+
state.property_cache[pdef.name] = None
|
|
463
|
+
|
|
464
|
+
changed_fn = getattr(client, f"{pdef.name}_changed", None)
|
|
465
|
+
if changed_fn is not None:
|
|
466
|
+
|
|
467
|
+
def _make_prop_cb(
|
|
468
|
+
_state: InstanceState, _pname: str
|
|
469
|
+
) -> Any:
|
|
470
|
+
def _cb(value: Any = None, **kwargs: Any) -> None:
|
|
471
|
+
_state.property_cache[_pname] = value
|
|
472
|
+
|
|
473
|
+
return _cb
|
|
474
|
+
|
|
475
|
+
changed_fn(_make_prop_cb(state, pdef.name))
|
|
476
|
+
|
|
477
|
+
with self._lock:
|
|
478
|
+
self._instances[key] = state
|
|
479
|
+
|
|
480
|
+
def _on_removed(
|
|
481
|
+
self, instance_id: str, plugin: StingerMCPPlugin
|
|
482
|
+
) -> None:
|
|
483
|
+
"""Deregister a departed service instance."""
|
|
484
|
+
plugin_name = plugin.get_plugin_name()
|
|
485
|
+
key = _instance_key(plugin_name, instance_id)
|
|
486
|
+
|
|
487
|
+
logger.info("Removed %s / %s", plugin_name, instance_id)
|
|
488
|
+
with self._lock:
|
|
489
|
+
self._instances.pop(key, None)
|
|
490
|
+
|
|
491
|
+
# ==================================================================
|
|
492
|
+
# Transports
|
|
493
|
+
# ==================================================================
|
|
494
|
+
|
|
495
|
+
def _init_options(self) -> InitializationOptions:
|
|
496
|
+
return InitializationOptions(
|
|
497
|
+
server_name="stinger-mcp",
|
|
498
|
+
server_version="0.1.0",
|
|
499
|
+
capabilities=self._server.get_capabilities(
|
|
500
|
+
notification_options=NotificationOptions(),
|
|
501
|
+
experimental_capabilities={},
|
|
502
|
+
),
|
|
503
|
+
)
|
|
504
|
+
|
|
505
|
+
async def run_stdio(self) -> None:
|
|
506
|
+
"""Run the MCP server over the *stdio* transport."""
|
|
507
|
+
self._loop = asyncio.get_running_loop()
|
|
508
|
+
self._connection = create_mqtt_connection()
|
|
509
|
+
self._load_plugins()
|
|
510
|
+
self._start_discovery()
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
async with mcp.server.stdio.stdio_server() as (read, write):
|
|
514
|
+
await self._server.run(read, write, self._init_options())
|
|
515
|
+
finally:
|
|
516
|
+
if self._connection is not None:
|
|
517
|
+
logger.info("Shutting down MQTT connection")
|
|
518
|
+
|
|
519
|
+
async def run_sse(
|
|
520
|
+
self, host: str = "0.0.0.0", port: int = 8000
|
|
521
|
+
) -> None:
|
|
522
|
+
"""Run the MCP server over the *SSE* transport."""
|
|
523
|
+
try:
|
|
524
|
+
from mcp.server.sse import SseServerTransport
|
|
525
|
+
from starlette.applications import Starlette
|
|
526
|
+
from starlette.requests import Request
|
|
527
|
+
from starlette.routing import Mount, Route
|
|
528
|
+
import uvicorn
|
|
529
|
+
except ImportError as exc:
|
|
530
|
+
raise RuntimeError(
|
|
531
|
+
"SSE transport requires additional packages. "
|
|
532
|
+
"Install with: pip install 'stinger-python-utils[mcp]'"
|
|
533
|
+
) from exc
|
|
534
|
+
|
|
535
|
+
self._loop = asyncio.get_running_loop()
|
|
536
|
+
self._connection = create_mqtt_connection()
|
|
537
|
+
self._load_plugins()
|
|
538
|
+
self._start_discovery()
|
|
539
|
+
|
|
540
|
+
sse = SseServerTransport("/messages/")
|
|
541
|
+
|
|
542
|
+
async def handle_sse(request: Request) -> None:
|
|
543
|
+
async with sse.connect_sse(
|
|
544
|
+
request.scope, request.receive, request._send
|
|
545
|
+
) as (read, write):
|
|
546
|
+
await self._server.run(read, write, self._init_options())
|
|
547
|
+
|
|
548
|
+
app = Starlette(
|
|
549
|
+
routes=[
|
|
550
|
+
Route("/sse", endpoint=handle_sse),
|
|
551
|
+
Mount("/messages/", app=sse.handle_post_message),
|
|
552
|
+
],
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
config = uvicorn.Config(app, host=host, port=port)
|
|
556
|
+
server = uvicorn.Server(config)
|
|
557
|
+
await server.serve()
|
|
@@ -14,6 +14,7 @@ class MessageCreator:
|
|
|
14
14
|
payload=payload.model_dump_json(by_alias=True).encode("utf-8"),
|
|
15
15
|
qos=1,
|
|
16
16
|
retain=False,
|
|
17
|
+
content_type="application/json",
|
|
17
18
|
)
|
|
18
19
|
|
|
19
20
|
@classmethod
|
|
@@ -26,6 +27,7 @@ class MessageCreator:
|
|
|
26
27
|
qos=1,
|
|
27
28
|
retain=True,
|
|
28
29
|
message_expiry_interval=expiry_seconds,
|
|
30
|
+
content_type="application/json",
|
|
29
31
|
)
|
|
30
32
|
|
|
31
33
|
@classmethod
|
|
@@ -55,6 +57,7 @@ class MessageCreator:
|
|
|
55
57
|
else correlation_id
|
|
56
58
|
),
|
|
57
59
|
user_properties={"ReturnCode": str(rc)},
|
|
60
|
+
content_type="application/json",
|
|
58
61
|
)
|
|
59
62
|
if (
|
|
60
63
|
debug_info is not None and msg_obj.user_properties is not None
|
|
@@ -95,6 +98,7 @@ class MessageCreator:
|
|
|
95
98
|
else correlation_id
|
|
96
99
|
),
|
|
97
100
|
user_properties={"ReturnCode": str(rc)},
|
|
101
|
+
content_type="application/json",
|
|
98
102
|
)
|
|
99
103
|
return msg_obj
|
|
100
104
|
|
|
@@ -133,6 +137,7 @@ class MessageCreator:
|
|
|
133
137
|
payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
134
138
|
qos=1,
|
|
135
139
|
retain=False,
|
|
140
|
+
content_type="application/json",
|
|
136
141
|
response_topic=response_topic,
|
|
137
142
|
correlation_data=(
|
|
138
143
|
correlation_id.encode("utf-8")
|
|
@@ -166,6 +171,7 @@ class MessageCreator:
|
|
|
166
171
|
payload=property_obj.model_dump_json(by_alias=True).encode("utf-8"),
|
|
167
172
|
qos=1,
|
|
168
173
|
retain=False,
|
|
174
|
+
content_type="application/json",
|
|
169
175
|
correlation_data=(
|
|
170
176
|
correlation_id.encode("utf-8")
|
|
171
177
|
if isinstance(correlation_id, str)
|
|
@@ -198,6 +204,7 @@ class MessageCreator:
|
|
|
198
204
|
qos=1,
|
|
199
205
|
retain=False,
|
|
200
206
|
response_topic=response_topic,
|
|
207
|
+
content_type="application/json",
|
|
201
208
|
correlation_data=(
|
|
202
209
|
correlation_id.encode("utf-8")
|
|
203
210
|
if isinstance(correlation_id, str)
|
|
@@ -20,6 +20,7 @@ class TestSignalMessage:
|
|
|
20
20
|
assert message.qos == 1
|
|
21
21
|
assert message.retain is False
|
|
22
22
|
assert b'"name":"test"' in message.payload
|
|
23
|
+
assert message.content_type == "application/json"
|
|
23
24
|
|
|
24
25
|
|
|
25
26
|
class TestStatusMessage:
|
|
@@ -32,6 +33,7 @@ class TestStatusMessage:
|
|
|
32
33
|
assert message.qos == 1
|
|
33
34
|
assert message.retain is True
|
|
34
35
|
assert message.message_expiry_interval == 3600
|
|
36
|
+
assert message.content_type == "application/json"
|
|
35
37
|
|
|
36
38
|
|
|
37
39
|
class TestErrorResponseMessage:
|
|
@@ -48,6 +50,7 @@ class TestErrorResponseMessage:
|
|
|
48
50
|
assert message.retain is False
|
|
49
51
|
assert message.user_properties["ReturnCode"] == "500"
|
|
50
52
|
assert message.correlation_data == b"abc-123"
|
|
53
|
+
assert message.content_type == "application/json"
|
|
51
54
|
|
|
52
55
|
def test_error_response_with_bytes_correlation_id(self):
|
|
53
56
|
"""Test error response with bytes correlation ID"""
|
|
@@ -59,6 +62,7 @@ class TestErrorResponseMessage:
|
|
|
59
62
|
|
|
60
63
|
assert message.correlation_data == b"xyz-789"
|
|
61
64
|
assert message.user_properties["ReturnCode"] == "400"
|
|
65
|
+
assert message.content_type == "application/json"
|
|
62
66
|
|
|
63
67
|
def test_error_response_with_debug_info(self):
|
|
64
68
|
"""Test error response includes debug info"""
|
|
@@ -69,6 +73,7 @@ class TestErrorResponseMessage:
|
|
|
69
73
|
)
|
|
70
74
|
|
|
71
75
|
assert message.user_properties["DebugInfo"] == "Something went wrong"
|
|
76
|
+
assert message.content_type == "application/json"
|
|
72
77
|
|
|
73
78
|
|
|
74
79
|
class TestResponseMessage:
|
|
@@ -86,6 +91,7 @@ class TestResponseMessage:
|
|
|
86
91
|
assert message.qos == 1
|
|
87
92
|
assert message.user_properties["ReturnCode"] == "200"
|
|
88
93
|
assert message.correlation_data == b"req-123"
|
|
94
|
+
assert message.content_type == "application/json"
|
|
89
95
|
|
|
90
96
|
def test_response_message_with_string(self):
|
|
91
97
|
"""Test response message with string payload"""
|
|
@@ -96,6 +102,7 @@ class TestResponseMessage:
|
|
|
96
102
|
)
|
|
97
103
|
|
|
98
104
|
assert message.payload == b"success"
|
|
105
|
+
assert message.content_type == "application/json"
|
|
99
106
|
|
|
100
107
|
def test_response_message_with_bytes(self):
|
|
101
108
|
"""Test response message with bytes payload"""
|
|
@@ -106,6 +113,7 @@ class TestResponseMessage:
|
|
|
106
113
|
)
|
|
107
114
|
|
|
108
115
|
assert message.payload == b"binary data"
|
|
116
|
+
assert message.content_type == "application/json"
|
|
109
117
|
|
|
110
118
|
|
|
111
119
|
class TestPropertyStateMessage:
|
|
@@ -147,6 +155,7 @@ class TestPropertyUpdateRequestMessage:
|
|
|
147
155
|
assert message.response_topic == "property/response"
|
|
148
156
|
assert message.user_properties["PropertyVersion"] == "1.0"
|
|
149
157
|
assert message.correlation_data == b"update-123"
|
|
158
|
+
assert message.content_type == "application/json"
|
|
150
159
|
|
|
151
160
|
|
|
152
161
|
class TestPropertyResponseMessage:
|
|
@@ -165,6 +174,7 @@ class TestPropertyResponseMessage:
|
|
|
165
174
|
assert message.user_properties["ReturnCode"] == "200"
|
|
166
175
|
assert message.user_properties["PropertyVersion"] == "1.0"
|
|
167
176
|
assert message.correlation_data == b"update-123"
|
|
177
|
+
assert message.content_type == "application/json"
|
|
168
178
|
|
|
169
179
|
def test_property_response_message_with_debug_info(self):
|
|
170
180
|
"""Test property response message with debug info"""
|
|
@@ -178,6 +188,7 @@ class TestPropertyResponseMessage:
|
|
|
178
188
|
)
|
|
179
189
|
|
|
180
190
|
assert message.user_properties["DebugInfo"] == "Update failed"
|
|
191
|
+
assert message.content_type == "application/json"
|
|
181
192
|
|
|
182
193
|
|
|
183
194
|
class TestRequestMessage:
|
|
@@ -194,6 +205,7 @@ class TestRequestMessage:
|
|
|
194
205
|
assert message.response_topic == "response/topic"
|
|
195
206
|
assert message.correlation_data is not None
|
|
196
207
|
assert len(message.correlation_data) > 0
|
|
208
|
+
assert message.content_type == "application/json"
|
|
197
209
|
|
|
198
210
|
def test_request_message_with_correlation_id(self):
|
|
199
211
|
"""Test request message with provided correlation ID"""
|
|
@@ -206,3 +218,4 @@ class TestRequestMessage:
|
|
|
206
218
|
)
|
|
207
219
|
|
|
208
220
|
assert message.correlation_data == b"req-456"
|
|
221
|
+
assert message.content_type == "application/json"
|
{stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.github/workflows/python-tests.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|