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.
Files changed (19) hide show
  1. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/PKG-INFO +1 -1
  2. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/pyproject.toml +1 -1
  3. stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/__init__.py +20 -0
  4. stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/__main__.py +56 -0
  5. stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/plugin.py +214 -0
  6. stinger_python_utils-0.1.8rc1/src/stinger_python_utils/mcp/server.py +557 -0
  7. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/message_creator.py +7 -0
  8. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/test/test_message_creator.py +13 -0
  9. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.github/workflows/python-tests.yml +0 -0
  10. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.github/workflows/python37.yml +0 -0
  11. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.gitignore +0 -0
  12. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.python-version +0 -0
  13. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/.vscode/settings.json +0 -0
  14. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/LICENSE +0 -0
  15. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/README.md +0 -0
  16. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/__init__.py +0 -0
  17. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/src/stinger_python_utils/return_codes.py +0 -0
  18. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/test/__init__.py +0 -0
  19. {stinger_python_utils-0.1.6 → stinger_python_utils-0.1.8rc1}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stinger-python-utils
3
- Version: 0.1.6
3
+ Version: 0.1.8rc1
4
4
  Summary: Common utilities for Stinger Python services.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "stinger-python-utils"
7
- version = "0.1.6"
7
+ version = "0.1.8.rc1"
8
8
  description = "Common utilities for Stinger Python services."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -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"