msp-py 0.1.0a1__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.
- msp/__init__.py +97 -0
- msp/core/__init__.py +1 -0
- msp/core/content.py +270 -0
- msp/core/exposure.py +90 -0
- msp/core/grammar.py +62 -0
- msp/core/grants.py +297 -0
- msp/core/manifest.py +448 -0
- msp/core/node.py +1009 -0
- msp/core/peer.py +153 -0
- msp/core/preset.py +162 -0
- msp/core/remote_tools.py +420 -0
- msp/core/schema.py +197 -0
- msp/core/signal.py +144 -0
- msp/discovery/__init__.py +1 -0
- msp/discovery/base.py +72 -0
- msp/discovery/directory.py +532 -0
- msp/discovery/manual.py +260 -0
- msp/mcp/__init__.py +30 -0
- msp/mcp/bridge.py +234 -0
- msp/mcp/client.py +170 -0
- msp/mcp/http.py +749 -0
- msp/mcp/jsonrpc.py +70 -0
- msp/mcp/server.py +253 -0
- msp/mcp/stdio.py +135 -0
- msp/patterns/__init__.py +10 -0
- msp/patterns/base.py +58 -0
- msp/patterns/multi_round.py +360 -0
- msp/patterns/task.py +1167 -0
- msp/patterns/task_store.py +90 -0
- msp/presence/__init__.py +1 -0
- msp/presence/base.py +56 -0
- msp/presence/directory.py +198 -0
- msp/presence/manual.py +113 -0
- msp/protocol/__init__.py +1 -0
- msp/protocol/matcher.py +36 -0
- msp/protocol/metrics.py +159 -0
- msp/protocol/protocol.py +1143 -0
- msp/transport/__init__.py +1 -0
- msp/transport/_common.py +38 -0
- msp/transport/base.py +122 -0
- msp/transport/registry.py +92 -0
- msp/transport/tcp.py +196 -0
- msp/transport/uds.py +143 -0
- msp/transport/websocket.py +433 -0
- msp/utils/__init__.py +1 -0
- msp/utils/atomic.py +46 -0
- msp/utils/manual_source.py +138 -0
- msp_py-0.1.0a1.dist-info/METADATA +537 -0
- msp_py-0.1.0a1.dist-info/RECORD +52 -0
- msp_py-0.1.0a1.dist-info/WHEEL +5 -0
- msp_py-0.1.0a1.dist-info/licenses/LICENSE +21 -0
- msp_py-0.1.0a1.dist-info/top_level.txt +1 -0
msp/__init__.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MSP — Model Sensing Protocol.
|
|
3
|
+
|
|
4
|
+
一个以「节点」为中心的分布式 AI 通讯协议。
|
|
5
|
+
Everything is a node. Everything is a signal. Every layer is extensible.
|
|
6
|
+
|
|
7
|
+
常用入口:
|
|
8
|
+
|
|
9
|
+
from msp import MSPNode, Preset
|
|
10
|
+
|
|
11
|
+
node = MSPNode("my_node", preset=Preset.FULL_PEER)
|
|
12
|
+
|
|
13
|
+
@node.tool("get_weather")
|
|
14
|
+
def get_weather(location: str) -> dict: ...
|
|
15
|
+
|
|
16
|
+
node.start()
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
# 版本号与 pyproject.toml 的 [project] version 保持同步。
|
|
20
|
+
__version__ = "0.1.0a1"
|
|
21
|
+
|
|
22
|
+
# --- 节点与预设 ---------------------------------------------------------
|
|
23
|
+
from .core.node import MSPNode
|
|
24
|
+
from .core.preset import Preset
|
|
25
|
+
from .core.peer import Peer
|
|
26
|
+
from .core.remote_tools import RemoteTool, ToolView
|
|
27
|
+
|
|
28
|
+
# --- 核心数据结构 --------------------------------------------------------
|
|
29
|
+
from .core.signal import Signal, SignalType
|
|
30
|
+
from .core.manifest import Manifest
|
|
31
|
+
from .core.content import (
|
|
32
|
+
TextContent,
|
|
33
|
+
ImageContent,
|
|
34
|
+
AudioContent,
|
|
35
|
+
EmbeddedResource,
|
|
36
|
+
ResourceLink,
|
|
37
|
+
BlobRef,
|
|
38
|
+
ToolResult,
|
|
39
|
+
ToolError,
|
|
40
|
+
block_from_dict,
|
|
41
|
+
blocks_from_list,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# --- 通讯模式 ------------------------------------------------------------
|
|
45
|
+
from .patterns.base import PatternHandler
|
|
46
|
+
from .patterns.multi_round import MultiRoundHandler
|
|
47
|
+
from .patterns.task import TaskHandler, TaskSnapshot, Artifact, TaskError, TaskCanceled
|
|
48
|
+
|
|
49
|
+
# --- 发现 / 宣告 / 传输 --------------------------------------------------
|
|
50
|
+
from .discovery.base import Discovery, Endpoint
|
|
51
|
+
from .presence.base import Presence
|
|
52
|
+
from .transport.base import Transport, Connection, ConnectionClosed
|
|
53
|
+
from .transport.registry import TransportRegistry
|
|
54
|
+
from .transport.tcp import TCPTransport
|
|
55
|
+
from .transport.websocket import WebSocketTransport
|
|
56
|
+
from .transport.uds import UDSTransport
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"__version__",
|
|
60
|
+
# 节点与预设
|
|
61
|
+
"MSPNode",
|
|
62
|
+
"Preset",
|
|
63
|
+
"Peer",
|
|
64
|
+
# 核心数据结构
|
|
65
|
+
"Signal",
|
|
66
|
+
"SignalType",
|
|
67
|
+
"Manifest",
|
|
68
|
+
"TextContent",
|
|
69
|
+
"ImageContent",
|
|
70
|
+
"AudioContent",
|
|
71
|
+
"EmbeddedResource",
|
|
72
|
+
"ResourceLink",
|
|
73
|
+
"BlobRef",
|
|
74
|
+
"ToolResult",
|
|
75
|
+
"ToolError",
|
|
76
|
+
"block_from_dict",
|
|
77
|
+
"blocks_from_list",
|
|
78
|
+
# 通讯模式
|
|
79
|
+
"PatternHandler",
|
|
80
|
+
"MultiRoundHandler",
|
|
81
|
+
"TaskHandler",
|
|
82
|
+
"TaskSnapshot",
|
|
83
|
+
"Artifact",
|
|
84
|
+
"TaskError",
|
|
85
|
+
"TaskCanceled",
|
|
86
|
+
# 发现 / 宣告 / 传输
|
|
87
|
+
"Discovery",
|
|
88
|
+
"Endpoint",
|
|
89
|
+
"Presence",
|
|
90
|
+
"Transport",
|
|
91
|
+
"Connection",
|
|
92
|
+
"ConnectionClosed",
|
|
93
|
+
"TransportRegistry",
|
|
94
|
+
"TCPTransport",
|
|
95
|
+
"WebSocketTransport",
|
|
96
|
+
"UDSTransport",
|
|
97
|
+
]
|
msp/core/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""core — 核心数据与入口:Signal、Manifest、ContentBlock、Preset、MSPNode。"""
|
msp/core/content.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ContentBlock — 工具返回结果的内容块。
|
|
3
|
+
|
|
4
|
+
兼容 MCP 的 content 数组结构:
|
|
5
|
+
- text: 纯文本
|
|
6
|
+
- image: 图片(Base64 内嵌)
|
|
7
|
+
- audio: 音频(Base64 内嵌)
|
|
8
|
+
- resource: 嵌入资源(文本或二进制)
|
|
9
|
+
- resource_link: 资源引用(URI)
|
|
10
|
+
- blob_ref: MSP 扩展,指向独立 blob 通道
|
|
11
|
+
|
|
12
|
+
所有内容块共享 to_dict() / from_dict() 接口。
|
|
13
|
+
未知类型的块原样返回,遵循宽容读取原则。
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ----------------------------------------------------------------------
|
|
23
|
+
# 内容块定义
|
|
24
|
+
# ----------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class TextContent:
|
|
28
|
+
text: str
|
|
29
|
+
annotations: dict = field(default_factory=dict)
|
|
30
|
+
|
|
31
|
+
def to_dict(self) -> dict:
|
|
32
|
+
d = {"type": "text", "text": self.text}
|
|
33
|
+
if self.annotations:
|
|
34
|
+
d["annotations"] = self.annotations
|
|
35
|
+
return d
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_dict(cls, d: dict) -> "TextContent":
|
|
39
|
+
return cls(
|
|
40
|
+
text=d.get("text", ""),
|
|
41
|
+
annotations=d.get("annotations", {}),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class ImageContent:
|
|
47
|
+
data: str # Base64 编码的图片字节
|
|
48
|
+
mime_type: str # "image/png" / "image/jpeg" / ...
|
|
49
|
+
annotations: dict = field(default_factory=dict)
|
|
50
|
+
|
|
51
|
+
def to_dict(self) -> dict:
|
|
52
|
+
d = {"type": "image", "data": self.data, "mimeType": self.mime_type}
|
|
53
|
+
if self.annotations:
|
|
54
|
+
d["annotations"] = self.annotations
|
|
55
|
+
return d
|
|
56
|
+
|
|
57
|
+
@classmethod
|
|
58
|
+
def from_dict(cls, d: dict) -> "ImageContent":
|
|
59
|
+
return cls(
|
|
60
|
+
data=d.get("data", ""),
|
|
61
|
+
mime_type=d.get("mimeType", ""),
|
|
62
|
+
annotations=d.get("annotations", {}),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class AudioContent:
|
|
68
|
+
data: str # Base64 编码的音频字节
|
|
69
|
+
mime_type: str # "audio/wav" / "audio/mp3" / ...
|
|
70
|
+
annotations: dict = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict:
|
|
73
|
+
d = {"type": "audio", "data": self.data, "mimeType": self.mime_type}
|
|
74
|
+
if self.annotations:
|
|
75
|
+
d["annotations"] = self.annotations
|
|
76
|
+
return d
|
|
77
|
+
|
|
78
|
+
@classmethod
|
|
79
|
+
def from_dict(cls, d: dict) -> "AudioContent":
|
|
80
|
+
return cls(
|
|
81
|
+
data=d.get("data", ""),
|
|
82
|
+
mime_type=d.get("mimeType", ""),
|
|
83
|
+
annotations=d.get("annotations", {}),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class EmbeddedResource:
|
|
89
|
+
"""嵌入资源:文本或二进制直接内嵌。"""
|
|
90
|
+
|
|
91
|
+
uri: str
|
|
92
|
+
mime_type: str = ""
|
|
93
|
+
text: str = "" # 文本资源用 text
|
|
94
|
+
blob: str = "" # 二进制资源用 blob(Base64)
|
|
95
|
+
annotations: dict = field(default_factory=dict)
|
|
96
|
+
|
|
97
|
+
def to_dict(self) -> dict:
|
|
98
|
+
d = {
|
|
99
|
+
"type": "resource",
|
|
100
|
+
"resource": {
|
|
101
|
+
"uri": self.uri,
|
|
102
|
+
"mimeType": self.mime_type,
|
|
103
|
+
},
|
|
104
|
+
}
|
|
105
|
+
if self.text:
|
|
106
|
+
d["resource"]["text"] = self.text
|
|
107
|
+
if self.blob:
|
|
108
|
+
d["resource"]["blob"] = self.blob
|
|
109
|
+
if self.annotations:
|
|
110
|
+
d["annotations"] = self.annotations
|
|
111
|
+
return d
|
|
112
|
+
|
|
113
|
+
@classmethod
|
|
114
|
+
def from_dict(cls, d: dict) -> "EmbeddedResource":
|
|
115
|
+
r = d.get("resource", {})
|
|
116
|
+
return cls(
|
|
117
|
+
uri=r.get("uri", ""),
|
|
118
|
+
mime_type=r.get("mimeType", ""),
|
|
119
|
+
text=r.get("text", ""),
|
|
120
|
+
blob=r.get("blob", ""),
|
|
121
|
+
annotations=d.get("annotations", {}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class ResourceLink:
|
|
127
|
+
"""资源引用:只给 URI,内容由对端自行获取。"""
|
|
128
|
+
|
|
129
|
+
uri: str
|
|
130
|
+
name: str = ""
|
|
131
|
+
mime_type: str = ""
|
|
132
|
+
description: str = ""
|
|
133
|
+
|
|
134
|
+
def to_dict(self) -> dict:
|
|
135
|
+
return {
|
|
136
|
+
"type": "resource_link",
|
|
137
|
+
"uri": self.uri,
|
|
138
|
+
"name": self.name,
|
|
139
|
+
"mimeType": self.mime_type,
|
|
140
|
+
"description": self.description,
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
@classmethod
|
|
144
|
+
def from_dict(cls, d: dict) -> "ResourceLink":
|
|
145
|
+
return cls(
|
|
146
|
+
uri=d.get("uri", ""),
|
|
147
|
+
name=d.get("name", ""),
|
|
148
|
+
mime_type=d.get("mimeType", ""),
|
|
149
|
+
description=d.get("description", ""),
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class BlobRef:
|
|
155
|
+
"""MSP 扩展:指向独立 blob 通道的引用。
|
|
156
|
+
|
|
157
|
+
大文件走独立的 blob_chunk 信号流,不塞进 tool_result。
|
|
158
|
+
"""
|
|
159
|
+
|
|
160
|
+
blob_id: str
|
|
161
|
+
mime_type: str
|
|
162
|
+
size: int = 0
|
|
163
|
+
checksum: str = ""
|
|
164
|
+
|
|
165
|
+
def to_dict(self) -> dict:
|
|
166
|
+
return {
|
|
167
|
+
"type": "blob_ref",
|
|
168
|
+
"blobId": self.blob_id,
|
|
169
|
+
"mimeType": self.mime_type,
|
|
170
|
+
"size": self.size,
|
|
171
|
+
"checksum": self.checksum,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
@classmethod
|
|
175
|
+
def from_dict(cls, d: dict) -> "BlobRef":
|
|
176
|
+
return cls(
|
|
177
|
+
blob_id=d.get("blobId", ""),
|
|
178
|
+
mime_type=d.get("mimeType", ""),
|
|
179
|
+
size=d.get("size", 0),
|
|
180
|
+
checksum=d.get("checksum", ""),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ----------------------------------------------------------------------
|
|
185
|
+
# 工厂:从字典构造内容块
|
|
186
|
+
# ----------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
_BLOCK_TYPES = {
|
|
189
|
+
"text": TextContent,
|
|
190
|
+
"image": ImageContent,
|
|
191
|
+
"audio": AudioContent,
|
|
192
|
+
"resource": EmbeddedResource,
|
|
193
|
+
"resource_link": ResourceLink,
|
|
194
|
+
"blob_ref": BlobRef,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def block_from_dict(d: dict):
|
|
199
|
+
"""从字典解析内容块。
|
|
200
|
+
|
|
201
|
+
未知类型原样返回(宽容读取)。
|
|
202
|
+
"""
|
|
203
|
+
t = d.get("type")
|
|
204
|
+
cls = _BLOCK_TYPES.get(t)
|
|
205
|
+
if cls is None:
|
|
206
|
+
return d
|
|
207
|
+
return cls.from_dict(d)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def blocks_from_list(items: list) -> list:
|
|
211
|
+
"""批量解析内容块。
|
|
212
|
+
|
|
213
|
+
非 dict 元素原样保留:与「未知类型原样保留」同一口径,不能让一个
|
|
214
|
+
异常元素(字符串 / 数字)调 .get 抛错带崩整份结果。
|
|
215
|
+
"""
|
|
216
|
+
return [
|
|
217
|
+
item if not isinstance(item, dict) else block_from_dict(item)
|
|
218
|
+
for item in items
|
|
219
|
+
]
|
|
220
|
+
|
|
221
|
+
# ----------------------------------------------------------------------
|
|
222
|
+
# ToolResult:工具调用的完整结果
|
|
223
|
+
# ----------------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
class ToolError(Exception):
|
|
226
|
+
"""工具执行失败。"""
|
|
227
|
+
pass
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
@dataclass
|
|
231
|
+
class ToolResult:
|
|
232
|
+
"""工具调用的完整结果。
|
|
233
|
+
|
|
234
|
+
简单场景:
|
|
235
|
+
result = node.call(...)
|
|
236
|
+
data = result.unwrap() # 直接拿 dict
|
|
237
|
+
|
|
238
|
+
丰富场景:
|
|
239
|
+
for block in result.content:
|
|
240
|
+
if block["type"] == "image":
|
|
241
|
+
show_image(block["data"])
|
|
242
|
+
|
|
243
|
+
错误处理:
|
|
244
|
+
if result.is_error:
|
|
245
|
+
print(result.error)
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
content: list = field(default_factory=list)
|
|
249
|
+
structured: Any = None
|
|
250
|
+
error: str = ""
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def is_error(self) -> bool:
|
|
254
|
+
return bool(self.error)
|
|
255
|
+
|
|
256
|
+
def unwrap(self):
|
|
257
|
+
"""返回结构化数据。错误时抛异常。"""
|
|
258
|
+
if self.error:
|
|
259
|
+
raise ToolError(self.error)
|
|
260
|
+
return self.structured
|
|
261
|
+
|
|
262
|
+
def text(self) -> str:
|
|
263
|
+
"""把所有 text 块拼起来。"""
|
|
264
|
+
parts = []
|
|
265
|
+
for block in self.content:
|
|
266
|
+
if isinstance(block, dict) and block.get("type") == "text":
|
|
267
|
+
parts.append(block.get("text", ""))
|
|
268
|
+
elif hasattr(block, "text"):
|
|
269
|
+
parts.append(block.text)
|
|
270
|
+
return "\n".join(parts)
|
msp/core/exposure.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""部署姿态:「监听是否暴露到网络」的判定与启动期告警。
|
|
2
|
+
|
|
3
|
+
与 C++ 侧的 ``core/exposure.hpp`` 同构:判定与文案都是**纯函数**,因此测试可以直接
|
|
4
|
+
断言文本,而节点只负责把结果打到日志里。
|
|
5
|
+
|
|
6
|
+
为什么需要它:监听地址与授权是**分开配置**的两件事。把门开到网络上却以为自己还
|
|
7
|
+
关着,或者反过来以为已经开了其实只绑了 localhost——这两类误解都**不会报错**,只是
|
|
8
|
+
行为与预期不符,属于最难查的一类问题。C++ 侧已因此加了这个告警,Python 侧原先没有。
|
|
9
|
+
|
|
10
|
+
三档姿态与 C++ 对齐(授权层落地后,原先那句「本实现没有授权层」不再成立,故改为按
|
|
11
|
+
授权表的实际姿态分档)。
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
# is_local_host 的家在 grants.py——「本机整张表绕过」与「这个 host 是不是本机」是同一件
|
|
17
|
+
# 事(security.md §3.5)。这里复用它,避免同一条判定在两处各写一遍;对外仍从这里可见。
|
|
18
|
+
from .grants import (
|
|
19
|
+
grants_is_default,
|
|
20
|
+
grants_is_permissive,
|
|
21
|
+
grants_summary,
|
|
22
|
+
is_local_host,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = ["is_local_host", "exposed_bindings", "exposure_warning"]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def exposed_bindings(listen_result: dict) -> list:
|
|
29
|
+
"""从 ``listen_all()`` 的返回值里挑出**已暴露到网络**的后端。
|
|
30
|
+
|
|
31
|
+
返回形如 ``["tcp=0.0.0.0"]``;未暴露时为空列表。
|
|
32
|
+
|
|
33
|
+
判定只看**实际绑定到的地址**(本端事实),不看配置写了什么、更不看对端自报了
|
|
34
|
+
什么。不计入暴露的三种情形:没有 ``host`` 键(uds 是本机专用后端)、``host``
|
|
35
|
+
为空(后端走自己的默认值)、``host`` 属于本机。
|
|
36
|
+
"""
|
|
37
|
+
exposed = []
|
|
38
|
+
if not isinstance(listen_result, dict):
|
|
39
|
+
return exposed
|
|
40
|
+
for ttype, info in listen_result.items():
|
|
41
|
+
if not isinstance(info, dict):
|
|
42
|
+
continue
|
|
43
|
+
address = info.get("address")
|
|
44
|
+
if not isinstance(address, dict):
|
|
45
|
+
continue
|
|
46
|
+
if "host" not in address:
|
|
47
|
+
continue # uds 之类没有 host
|
|
48
|
+
host = address.get("host") or ""
|
|
49
|
+
if not isinstance(host, str) or not host:
|
|
50
|
+
continue # 空 host = 后端默认值(localhost)
|
|
51
|
+
if is_local_host(host):
|
|
52
|
+
continue
|
|
53
|
+
exposed.append(f"{ttype}={host}")
|
|
54
|
+
return exposed
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def exposure_warning(exposed: list, grants=None) -> str:
|
|
58
|
+
"""启动期告警文本;**不需要告警时返回空串**(调用方据此决定是否打日志)。
|
|
59
|
+
|
|
60
|
+
与 C++ 侧同一组三档:
|
|
61
|
+
|
|
62
|
+
- 未暴露 → 空串;
|
|
63
|
+
- 暴露 + 授权表是**默认姿态** → 告警:连得上但什么都做不了(最容易被误解的一档,
|
|
64
|
+
表现为「对端调不动任何工具」,很容易被当成程序坏了);
|
|
65
|
+
- 暴露 + 授权表**放行了权限** → 告警:网络可达者都能行使这些权限,请确认;
|
|
66
|
+
- 暴露 + 一张**显式且很窄**的表 → 空串(想清楚了的部署不该被反复告诫,
|
|
67
|
+
否则告警会变成噪声,真出问题时反而被忽略)。
|
|
68
|
+
"""
|
|
69
|
+
if not exposed:
|
|
70
|
+
return ""
|
|
71
|
+
where = "监听已暴露到网络(" + "、".join(exposed) + ")"
|
|
72
|
+
|
|
73
|
+
if grants_is_default(grants):
|
|
74
|
+
return (
|
|
75
|
+
where
|
|
76
|
+
+ ",但授权表是**默认姿态**(对非本机对端全拒,只保留应答通道):"
|
|
77
|
+
"网络对端能连上,却调不动任何工具、也收不到任何广播。"
|
|
78
|
+
"要跨机使用请显式配置 config[\"grants\"];只在本机使用请把监听地址留在"
|
|
79
|
+
" 127.0.0.1(config[\"listen\"] 不写即为默认)。"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
if grants_is_permissive(grants):
|
|
83
|
+
return (
|
|
84
|
+
where
|
|
85
|
+
+ ",且授权表**放行了权限**:"
|
|
86
|
+
+ grants_summary(grants)
|
|
87
|
+
+ "。网络可达者都能行使这些权限,请确认这是你要的。"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
return ""
|
msp/core/grammar.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""命名文法校验。
|
|
2
|
+
|
|
3
|
+
**权威源**:``schema/2026-10-10/msp.yaml`` 的 ``grammars`` 段(对应
|
|
4
|
+
``docs/spec/README.md`` §5.3)。这里逐字照抄那几条正则并注明出处——正则只该有**一份**
|
|
5
|
+
真相,两处各写一遍迟早会漂移。
|
|
6
|
+
|
|
7
|
+
与 C++ 侧的 ``core/grammar.hpp`` 同构。
|
|
8
|
+
|
|
9
|
+
为什么需要它:MCP 允许的工具名是 ``A-Za-z0-9_-.`` 且最长 128 字符(均为 SHOULD),
|
|
10
|
+
而 MSP 要求 ``^[a-z0-9_-]+$``(MUST,无大写、无点号)。逐字注册远端名会产出**违反 MSP
|
|
11
|
+
自身文法**的工具名——本地字典查表仍能命中,但清单与其他实现无法按规范解析
|
|
12
|
+
(规范 ``interop.md`` §9 G11)。
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
|
|
19
|
+
# 逐字来自 schema/2026-10-10/msp.yaml 的 grammars 段
|
|
20
|
+
_NODE_ID = re.compile(r"^[a-z0-9_-]+$")
|
|
21
|
+
_PATH = re.compile(r"^[a-z0-9_-]+(\.[a-z0-9_-]+)*$")
|
|
22
|
+
_TOOL_NAME = re.compile(r"^[a-z0-9_-]+$")
|
|
23
|
+
# pattern_id **恰好**一个斜杠:只有 `msp/task` 这种形态合法,
|
|
24
|
+
# `msp` 与 `msp/task/x` 都不合法(正则本身就把这条钉住了)
|
|
25
|
+
_PATTERN_ID = re.compile(r"^[a-z0-9_-]+/[a-z0-9_-]+$")
|
|
26
|
+
# 状态是闭集;注意 `input-required` 用**连字符**(对齐 A2A TaskState),
|
|
27
|
+
# 而信号类型 `input_required` 用**下划线**——两者不得互换、不得归一化。
|
|
28
|
+
_TASK_STATE = re.compile(
|
|
29
|
+
r"^(submitted|working|input-required|completed|failed|canceled)$"
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _matches(pattern: re.Pattern, value) -> bool:
|
|
34
|
+
# 非字符串一律判否,而不是抛异常:校验器的调用方通常在处理外部数据。
|
|
35
|
+
# 用 fullmatch 而不是 match + `$`:`$` 还能匹配串尾换行之前,
|
|
36
|
+
# "node_a\n" 会被 `^[a-z0-9_-]+$` 错误放行。
|
|
37
|
+
return isinstance(value, str) and pattern.fullmatch(value) is not None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def is_valid_node_id(value) -> bool:
|
|
41
|
+
"""节点 id:``^[a-z0-9_-]+$``。"""
|
|
42
|
+
return _matches(_NODE_ID, value)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def is_valid_path(value) -> bool:
|
|
46
|
+
"""信号路径:``^[a-z0-9_-]+(\\.[a-z0-9_-]+)*$``(点分段,无空段)。"""
|
|
47
|
+
return _matches(_PATH, value)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_valid_tool_name(value) -> bool:
|
|
51
|
+
"""工具名:``^[a-z0-9_-]+$``(比 MCP 更严:无大写、无点号)。"""
|
|
52
|
+
return _matches(_TOOL_NAME, value)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def is_valid_pattern_id(value) -> bool:
|
|
56
|
+
"""模式 id:``^[a-z0-9_-]+/[a-z0-9_-]+$``(恰好一个斜杠)。"""
|
|
57
|
+
return _matches(_PATTERN_ID, value)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def is_valid_task_state(value) -> bool:
|
|
61
|
+
"""任务状态:闭集,且 ``input-required`` 用连字符。"""
|
|
62
|
+
return _matches(_TASK_STATE, value)
|