stinger-python-utils 0.1.6__py3-none-any.whl → 0.1.8__py3-none-any.whl
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/mcp/__init__.py +20 -0
- stinger_python_utils/mcp/__main__.py +58 -0
- stinger_python_utils/mcp/plugin.py +220 -0
- stinger_python_utils/mcp/server.py +633 -0
- stinger_python_utils/message_creator.py +24 -0
- stinger_python_utils-0.1.8.dist-info/METADATA +276 -0
- stinger_python_utils-0.1.8.dist-info/RECORD +12 -0
- {stinger_python_utils-0.1.6.dist-info → stinger_python_utils-0.1.8.dist-info}/WHEEL +1 -1
- stinger_python_utils-0.1.8.dist-info/entry_points.txt +2 -0
- stinger_python_utils-0.1.6.dist-info/METADATA +0 -84
- stinger_python_utils-0.1.6.dist-info/RECORD +0 -7
- {stinger_python_utils-0.1.6.dist-info → stinger_python_utils-0.1.8.dist-info}/licenses/LICENSE +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,58 @@
|
|
|
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", "streamable-http"],
|
|
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/streamable-http transport (default: 0.0.0.0)",
|
|
25
|
+
)
|
|
26
|
+
parser.add_argument(
|
|
27
|
+
"--port",
|
|
28
|
+
type=int,
|
|
29
|
+
default=8000,
|
|
30
|
+
help="Port for SSE/streamable-http 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
|
+
elif args.transport == "streamable-http":
|
|
54
|
+
asyncio.run(server.run_streamable_http(host=args.host, port=args.port))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
if __name__ == "__main__":
|
|
58
|
+
main()
|
|
@@ -0,0 +1,220 @@
|
|
|
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
|
+
from pydantic import BaseModel
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ------------------------------------------------------------------
|
|
24
|
+
# Data models
|
|
25
|
+
# ------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class SignalDefinition:
|
|
30
|
+
"""Describes a signal emitted by a stinger-ipc client.
|
|
31
|
+
|
|
32
|
+
The MCP server calls ``client.receive_{name}(callback)`` and stores
|
|
33
|
+
received payloads in a per-instance mailbox exposed as an MCP
|
|
34
|
+
resource.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
name: str
|
|
38
|
+
description: str = ""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PropertyDefinition:
|
|
43
|
+
"""Describes a property on a stinger-ipc client.
|
|
44
|
+
|
|
45
|
+
Every property is exposed as an MCP **resource**. Writable
|
|
46
|
+
properties (``readonly=False``) additionally get an MCP **tool**
|
|
47
|
+
whose ``inputSchema`` is *schema*.
|
|
48
|
+
|
|
49
|
+
*schema* must be a valid `JSON Schema`_ object.
|
|
50
|
+
|
|
51
|
+
.. _JSON Schema: https://json-schema.org/
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
name: str
|
|
55
|
+
schema: dict[str, Any] = field(
|
|
56
|
+
default_factory=lambda: {
|
|
57
|
+
"type": "object",
|
|
58
|
+
"properties": {"value": {}},
|
|
59
|
+
"required": ["value"],
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
readonly: bool = True
|
|
63
|
+
description: str = ""
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class MethodDefinition:
|
|
68
|
+
"""Describes a callable method on a stinger-ipc client.
|
|
69
|
+
|
|
70
|
+
Each method is exposed as an MCP **tool** whose ``inputSchema`` is
|
|
71
|
+
derived from *arguments_model* via ``model_json_schema()``.
|
|
72
|
+
|
|
73
|
+
*arguments_model* must be a :class:`pydantic.BaseModel` subclass.
|
|
74
|
+
When the tool is invoked, the raw JSON arguments are loaded into
|
|
75
|
+
an instance of this model and the model is passed to
|
|
76
|
+
``call_{method_name}`` on the client.
|
|
77
|
+
|
|
78
|
+
If *arguments_model* is ``None`` the tool accepts no arguments.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
name: str
|
|
82
|
+
arguments_model: type[BaseModel] | None = None
|
|
83
|
+
description: str = ""
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Plugin ABC
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class StingerMCPPlugin(ABC):
|
|
92
|
+
"""ABC that stevedore plugins must implement.
|
|
93
|
+
|
|
94
|
+
Register concrete subclasses as entry-points under the namespace
|
|
95
|
+
``stinger_python_utils.mcp_plugins``::
|
|
96
|
+
|
|
97
|
+
# pyproject.toml of the *plugin* package
|
|
98
|
+
[project.entry-points."stinger_python_utils.mcp_plugins"]
|
|
99
|
+
my_service = "my_package.plugin:MyPlugin"
|
|
100
|
+
|
|
101
|
+
The MCP server loads all registered plugins at startup and:
|
|
102
|
+
|
|
103
|
+
1. Instantiates the **discoverer** (from :meth:`get_discovery_class`)
|
|
104
|
+
with a shared ``pyqttier`` ``IBrokerConnection``.
|
|
105
|
+
2. On each discovered instance, instantiates the **client** (from
|
|
106
|
+
:meth:`get_client_class`) with ``(connection, discovered_instance)``.
|
|
107
|
+
3. Wires signals, properties, and methods into the MCP protocol.
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# ------------------------------------------------------------------
|
|
111
|
+
# Required – every plugin must implement these
|
|
112
|
+
# ------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
@abstractmethod
|
|
115
|
+
def get_plugin_name(self) -> str:
|
|
116
|
+
"""Return a unique, short identifier for this plugin.
|
|
117
|
+
|
|
118
|
+
Used as the scheme/prefix in MCP resource URIs and tool names
|
|
119
|
+
(e.g. ``"lights"`` → ``lights://instance123/property/brightness``).
|
|
120
|
+
"""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
@abstractmethod
|
|
124
|
+
def get_discovery_class(self) -> type:
|
|
125
|
+
"""Return the *Discoverer* class for this service type.
|
|
126
|
+
|
|
127
|
+
The MCP server instantiates it as::
|
|
128
|
+
|
|
129
|
+
discoverer = DiscovererClass(connection)
|
|
130
|
+
|
|
131
|
+
The class **must** expose:
|
|
132
|
+
|
|
133
|
+
* ``add_discovered_service_callback(cb)`` – *cb* receives a
|
|
134
|
+
``DiscoveredInstance`` (opaque pydantic model with at minimum
|
|
135
|
+
an ``instance_id: str`` attribute).
|
|
136
|
+
* ``add_removed_service_callback(cb)`` – *cb* receives the
|
|
137
|
+
``instance_id: str`` of the departed instance.
|
|
138
|
+
"""
|
|
139
|
+
...
|
|
140
|
+
|
|
141
|
+
@abstractmethod
|
|
142
|
+
def get_client_class(self) -> type:
|
|
143
|
+
"""Return the *Client* class for this service type.
|
|
144
|
+
|
|
145
|
+
The MCP server instantiates it as::
|
|
146
|
+
|
|
147
|
+
client = ClientClass(connection, discovered_instance)
|
|
148
|
+
"""
|
|
149
|
+
...
|
|
150
|
+
|
|
151
|
+
@abstractmethod
|
|
152
|
+
def get_signals(self) -> list[SignalDefinition]:
|
|
153
|
+
"""Return the list of signals the client can emit."""
|
|
154
|
+
...
|
|
155
|
+
|
|
156
|
+
@abstractmethod
|
|
157
|
+
def get_properties(self) -> list[PropertyDefinition]:
|
|
158
|
+
"""Return the list of properties the client exposes."""
|
|
159
|
+
...
|
|
160
|
+
|
|
161
|
+
@abstractmethod
|
|
162
|
+
def get_methods(self) -> list[MethodDefinition]:
|
|
163
|
+
"""Return the list of callable methods the client exposes."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
# ------------------------------------------------------------------
|
|
167
|
+
# Defaults – override for non-standard behaviour
|
|
168
|
+
# ------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
def read_property(self, client: Any, prop_name: str) -> Any:
|
|
171
|
+
"""Read a property value from *client*.
|
|
172
|
+
|
|
173
|
+
The default implementation returns ``getattr(client, prop_name)``.
|
|
174
|
+
"""
|
|
175
|
+
return getattr(client, prop_name)
|
|
176
|
+
|
|
177
|
+
def write_property(
|
|
178
|
+
self, client: Any, prop_name: str, arguments: dict[str, Any]
|
|
179
|
+
) -> None:
|
|
180
|
+
"""Set a property on *client* from MCP tool *arguments*.
|
|
181
|
+
|
|
182
|
+
*arguments* is the dict parsed from the tool's JSON Schema
|
|
183
|
+
input. The default implementation does::
|
|
184
|
+
|
|
185
|
+
setattr(client, prop_name, arguments["value"])
|
|
186
|
+
|
|
187
|
+
Override when the property value is a composite type that must
|
|
188
|
+
be reconstructed from several arguments.
|
|
189
|
+
"""
|
|
190
|
+
setattr(client, prop_name, list(arguments.values())[0])
|
|
191
|
+
|
|
192
|
+
def call_method(
|
|
193
|
+
self, client: Any, method_name: str, arguments: BaseModel | None
|
|
194
|
+
) -> Any:
|
|
195
|
+
"""Invoke *method_name* on *client* with *arguments*.
|
|
196
|
+
|
|
197
|
+
*arguments* is a validated :class:`pydantic.BaseModel` instance
|
|
198
|
+
(or ``None`` when the method takes no parameters).
|
|
199
|
+
|
|
200
|
+
The default implementation calls::
|
|
201
|
+
|
|
202
|
+
getattr(client, f"call_{method_name}")(arguments)
|
|
203
|
+
|
|
204
|
+
and returns whatever the method returns (typically a
|
|
205
|
+
``concurrent.futures.Future``).
|
|
206
|
+
"""
|
|
207
|
+
method = getattr(client, f"call_{method_name}")
|
|
208
|
+
if arguments is None:
|
|
209
|
+
return method()
|
|
210
|
+
return method(arguments)
|
|
211
|
+
|
|
212
|
+
def serialize_property(self, prop_name: str, value: Any) -> str:
|
|
213
|
+
"""Serialize a property *value* to a JSON string for the MCP resource.
|
|
214
|
+
|
|
215
|
+
The default implementation handles pydantic ``BaseModel``
|
|
216
|
+
instances and falls back to :func:`json.dumps`.
|
|
217
|
+
"""
|
|
218
|
+
if hasattr(value, "model_dump_json"):
|
|
219
|
+
return value.model_dump_json()
|
|
220
|
+
return json.dumps(value, default=str)
|