@yottameta/yotta-verify-mcp-plugin 0.0.0 → 0.2.3
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.
- package/.agents/plugins/marketplace.json +25 -0
- package/.claude-plugin/marketplace.json +25 -0
- package/LICENSE +21 -0
- package/README.md +107 -0
- package/mcp.json +12 -0
- package/package.json +32 -13
- package/plugin.json +20 -0
- package/skills/yotta-verify-mcp/LICENSE +21 -0
- package/skills/yotta-verify-mcp/NOTICE +15 -0
- package/skills/yotta-verify-mcp/SKILL.md +144 -0
- package/skills/yotta-verify-mcp/assets/banner.png +0 -0
- package/skills/yotta-verify-mcp/bin/yotta-verify-mcp.js +66 -0
- package/skills/yotta-verify-mcp/references/trust-checklist.md +44 -0
- package/skills/yotta-verify-mcp/scripts/threat_engine.py +350 -0
- package/skills/yotta-verify-mcp/scripts/verify_rules.py +449 -0
- package/skills/yotta-verify-mcp/scripts/yotta_verify.py +902 -0
- package/skills/yotta-verify-mcp/scripts/yotta_verify_mcp.py +335 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""yotta_verify_mcp.py — 元信 MCP server(yotta-verify-mcp)。
|
|
4
|
+
|
|
5
|
+
stdio MCP server(JSON-RPC 2.0,换行分隔),把 yotta-verify(元信)装前安全扫描
|
|
6
|
+
暴露为 MCP 工具:scan_skill / generate_badge / gate_check / get_report。
|
|
7
|
+
|
|
8
|
+
复用 yotta_verify.py 内核(同一规则表 verify_rules.py 单源),本地离线静态扫描;
|
|
9
|
+
不上传被测内容、不执行被测代码、不联网(目录扫描完全离线;npm 包扫描仅下载公开包)。
|
|
10
|
+
|
|
11
|
+
运行:python scripts/yotta_verify_mcp.py
|
|
12
|
+
MCP 客户端配置:
|
|
13
|
+
{"mcpServers":{"yotta-verify-mcp":{"command":"python",
|
|
14
|
+
"args":["<绝对路径>/scripts/yotta_verify_mcp.py"]}}}
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
_HERE = Path(__file__).resolve().parent
|
|
26
|
+
sys.path.insert(0, str(_HERE))
|
|
27
|
+
|
|
28
|
+
import yotta_verify as yv # noqa: E402
|
|
29
|
+
|
|
30
|
+
VERSION = "0.2.3"
|
|
31
|
+
TOOL_NAME = "yotta-verify-mcp"
|
|
32
|
+
CN_NAME = "元信"
|
|
33
|
+
MCP_PROTOCOL = "2025-03-26"
|
|
34
|
+
SERVERS = {TOOL_NAME: {"name": TOOL_NAME, "cn": CN_NAME, "version": VERSION}}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _tool_error(message, extra=None):
|
|
38
|
+
"""构造工具调用错误结果(isError=true)。"""
|
|
39
|
+
payload = {"error": message}
|
|
40
|
+
if extra:
|
|
41
|
+
payload.update(extra)
|
|
42
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False, indent=2)}],
|
|
43
|
+
"isError": True}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _name_hint(path):
|
|
47
|
+
return Path(path).name
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _resolve_target(target, tmp_dirs):
|
|
51
|
+
"""把 target 解析为本地路径/压缩包路径;npm 包名则 npm pack 到临时目录。"""
|
|
52
|
+
p = Path(target)
|
|
53
|
+
if p.exists():
|
|
54
|
+
return str(p)
|
|
55
|
+
# 非本地路径:尝试当作 npm 包名下载后本地扫描(仅下载公开包,不上传被测内容)
|
|
56
|
+
tmp = tempfile.mkdtemp(prefix="yottamcp-")
|
|
57
|
+
tmp_dirs.append(tmp)
|
|
58
|
+
try:
|
|
59
|
+
r = subprocess.run(
|
|
60
|
+
["npm", "pack", target, "--pack-destination", tmp],
|
|
61
|
+
capture_output=True, text=True, timeout=120)
|
|
62
|
+
except Exception as e: # noqa: BLE001
|
|
63
|
+
raise ValueError("npm pack 调用失败:%s" % e)
|
|
64
|
+
if r.returncode != 0:
|
|
65
|
+
err = (r.stderr or r.stdout or "").strip()[:300]
|
|
66
|
+
raise ValueError("无法解析目标 %s(不是本地路径,且 npm pack 失败):%s" % (target, err))
|
|
67
|
+
tarballs = [f for f in os.listdir(tmp) if f.endswith(".tgz")]
|
|
68
|
+
if not tarballs:
|
|
69
|
+
raise ValueError("npm pack 未产出 tarball:%s" % target)
|
|
70
|
+
return os.path.join(tmp, tarballs[0])
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _scan(target):
|
|
74
|
+
"""扫描 target,返回 (findings, counts, verdict, meta, error)。"""
|
|
75
|
+
tmp_dirs = []
|
|
76
|
+
try:
|
|
77
|
+
path = _resolve_target(target, tmp_dirs)
|
|
78
|
+
findings, counts, verdict, meta = yv.scan_core(path, name_hint=_name_hint(path))
|
|
79
|
+
return findings, counts, verdict, meta, None
|
|
80
|
+
except SystemExit as e:
|
|
81
|
+
return None, None, None, None, str(e)
|
|
82
|
+
except Exception as e: # noqa: BLE001
|
|
83
|
+
return None, None, None, None, str(e)
|
|
84
|
+
finally:
|
|
85
|
+
for d in tmp_dirs:
|
|
86
|
+
shutil.rmtree(d, ignore_errors=True)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _tool_scan_skill(params):
|
|
90
|
+
target = params.get("target")
|
|
91
|
+
if not target:
|
|
92
|
+
return _tool_error("scan_skill 需要 target 参数")
|
|
93
|
+
findings, counts, verdict, meta, err = _scan(target)
|
|
94
|
+
if err:
|
|
95
|
+
return _tool_error("扫描失败:%s" % err, {"verdict": None, "meta": {"target": target}})
|
|
96
|
+
text = yv.render_json(findings, counts, verdict, meta)
|
|
97
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _tool_generate_badge(params):
|
|
101
|
+
target = params.get("target")
|
|
102
|
+
verdict = params.get("verdict")
|
|
103
|
+
if not verdict and target:
|
|
104
|
+
findings, counts, v, meta, err = _scan(target)
|
|
105
|
+
if err:
|
|
106
|
+
return _tool_error("扫描失败:%s" % err, {"meta": {"target": target}})
|
|
107
|
+
verdict = v
|
|
108
|
+
if not verdict:
|
|
109
|
+
verdict = yv.VERDICT_SAFE
|
|
110
|
+
validate = params.get("validate")
|
|
111
|
+
if validate is not None:
|
|
112
|
+
validate = str(validate).upper()
|
|
113
|
+
if validate not in ("PASS", "FAIL"):
|
|
114
|
+
validate = None
|
|
115
|
+
extra = {
|
|
116
|
+
"validate": validate,
|
|
117
|
+
"vetter": params.get("vetter"),
|
|
118
|
+
"audit": params.get("audit"),
|
|
119
|
+
"version": params.get("version") or yv.VERSION,
|
|
120
|
+
"tests": params.get("tests"),
|
|
121
|
+
}
|
|
122
|
+
svg, url = yv.build_badges(verdict, extra)
|
|
123
|
+
result = {"verdict": verdict, "svg": svg, "url": url}
|
|
124
|
+
out = params.get("out")
|
|
125
|
+
if out:
|
|
126
|
+
Path(out).parent.mkdir(parents=True, exist_ok=True)
|
|
127
|
+
Path(out).write_text(svg, encoding="utf-8")
|
|
128
|
+
result["file"] = str(out)
|
|
129
|
+
return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}],
|
|
130
|
+
"isError": False}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _tool_gate_check(params):
|
|
134
|
+
target = params.get("target")
|
|
135
|
+
if not target:
|
|
136
|
+
return _tool_error("gate_check 需要 target 参数")
|
|
137
|
+
findings, counts, verdict, meta, err = _scan(target)
|
|
138
|
+
if err:
|
|
139
|
+
return _tool_error("扫描失败:%s" % err, {"meta": {"target": target}})
|
|
140
|
+
limit_sev = (params.get("max_severity") or "medium").lower()
|
|
141
|
+
limit = yv._SEVERITY_VALUE.get(limit_sev, 1)
|
|
142
|
+
worst = yv.verdict_worst(findings)
|
|
143
|
+
worst_val = yv._SEVERITY_VALUE.get(worst, 0)
|
|
144
|
+
code = yv.exit_code_of(verdict)
|
|
145
|
+
passed = worst_val <= limit
|
|
146
|
+
result = {
|
|
147
|
+
"verdict": verdict,
|
|
148
|
+
"worst": worst,
|
|
149
|
+
"max_severity": limit_sev,
|
|
150
|
+
"pass": passed,
|
|
151
|
+
"code": code if passed else max(code, 1),
|
|
152
|
+
}
|
|
153
|
+
return {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False, indent=2)}],
|
|
154
|
+
"isError": False}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _tool_get_report(params):
|
|
158
|
+
target = params.get("target")
|
|
159
|
+
if not target:
|
|
160
|
+
return _tool_error("get_report 需要 target 参数")
|
|
161
|
+
fmt = (params.get("format") or "markdown").lower()
|
|
162
|
+
if fmt not in ("json", "markdown"):
|
|
163
|
+
return _tool_error("get_report 的 format 只能为 json 或 markdown")
|
|
164
|
+
findings, counts, verdict, meta, err = _scan(target)
|
|
165
|
+
if err:
|
|
166
|
+
return _tool_error("扫描失败:%s" % err, {"meta": {"target": target}})
|
|
167
|
+
if fmt == "json":
|
|
168
|
+
text = yv.render_json(findings, counts, verdict, meta)
|
|
169
|
+
else:
|
|
170
|
+
text = yv.render_markdown(findings, counts, verdict, meta)
|
|
171
|
+
out = params.get("out")
|
|
172
|
+
if out:
|
|
173
|
+
Path(out).write_text(text, encoding="utf-8")
|
|
174
|
+
return {"content": [{"type": "text", "text": text}], "isError": False}
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
TOOL_HANDLERS = {
|
|
178
|
+
"scan_skill": _tool_scan_skill,
|
|
179
|
+
"generate_badge": _tool_generate_badge,
|
|
180
|
+
"gate_check": _tool_gate_check,
|
|
181
|
+
"get_report": _tool_get_report,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def mcp_tools():
|
|
186
|
+
"""返回 MCP tools 列表(name / description / inputSchema)。"""
|
|
187
|
+
return [
|
|
188
|
+
{
|
|
189
|
+
"name": "scan_skill",
|
|
190
|
+
"description": (
|
|
191
|
+
"装前安全扫描(元信 yotta-verify)。扫描一个技能目录、.tgz 压缩包或 npm 包,"
|
|
192
|
+
"返回 verdict(SAFE TO INSTALL / INSTALL WITH CAUTION / REVIEW REQUIRED / "
|
|
193
|
+
"DO NOT INSTALL)+ 按严重级统计 + 发现列表(提示注入/危险模式/SKILL 完整性)。"
|
|
194
|
+
"本地离线静态扫描,不上传被测内容、不执行被测代码。"
|
|
195
|
+
),
|
|
196
|
+
"inputSchema": {
|
|
197
|
+
"type": "object",
|
|
198
|
+
"properties": {
|
|
199
|
+
"target": {"type": "string",
|
|
200
|
+
"description": "技能目录路径、.tgz/.tar.gz 路径,或 npm 包名(自动 npm pack 下载后本地扫描)"}
|
|
201
|
+
},
|
|
202
|
+
"required": ["target"],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
"name": "generate_badge",
|
|
207
|
+
"description": (
|
|
208
|
+
"生成 audited 徽章(本地 SVG + shields.io URL)。可给 target 自动扫描取得 verdict,"
|
|
209
|
+
"或直接给 verdict,并可附加 validate/vetter/audit/version/tests 分段。"
|
|
210
|
+
),
|
|
211
|
+
"inputSchema": {
|
|
212
|
+
"type": "object",
|
|
213
|
+
"properties": {
|
|
214
|
+
"target": {"type": "string", "description": "可选:目录/包,用于自动扫描取得 verdict"},
|
|
215
|
+
"verdict": {"type": "string", "description": "可选:直接指定 verdict"},
|
|
216
|
+
"validate": {"type": "string", "enum": ["pass", "fail"], "description": "validate-skill 结果"},
|
|
217
|
+
"vetter": {"type": "string", "description": "vetter 结论"},
|
|
218
|
+
"audit": {"type": "string", "description": "audit 结论"},
|
|
219
|
+
"version": {"type": "string", "description": "版本标签"},
|
|
220
|
+
"tests": {"type": "integer", "description": "测试数"},
|
|
221
|
+
"out": {"type": "string", "description": "可选:将 SVG 写入该路径"}
|
|
222
|
+
},
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
"name": "gate_check",
|
|
227
|
+
"description": (
|
|
228
|
+
"CI 闸门。扫描 target,判断最严重级是否超过 max_severity 阈值(默认 medium);"
|
|
229
|
+
"返回 pass/verdict/worst/exit code。"
|
|
230
|
+
),
|
|
231
|
+
"inputSchema": {
|
|
232
|
+
"type": "object",
|
|
233
|
+
"properties": {
|
|
234
|
+
"target": {"type": "string", "description": "目录/包路径"},
|
|
235
|
+
"max_severity": {"type": "string", "enum": ["info", "low", "medium", "high", "critical"],
|
|
236
|
+
"description": "允许的最大严重级(默认 medium)"}
|
|
237
|
+
},
|
|
238
|
+
"required": ["target"],
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{
|
|
242
|
+
"name": "get_report",
|
|
243
|
+
"description": (
|
|
244
|
+
"生成验证报告。扫描 target 并按 format 返回 Markdown 或 JSON 报告;"
|
|
245
|
+
"可写 out 路径。报告与 CLI 同一格式(verify_rules.py 单源)。"
|
|
246
|
+
),
|
|
247
|
+
"inputSchema": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"properties": {
|
|
250
|
+
"target": {"type": "string", "description": "目录/包路径"},
|
|
251
|
+
"format": {"type": "string", "enum": ["json", "markdown"], "description": "报告格式(默认 markdown)"},
|
|
252
|
+
"out": {"type": "string", "description": "可选:将报告写入该路径"}
|
|
253
|
+
},
|
|
254
|
+
"required": ["target"],
|
|
255
|
+
},
|
|
256
|
+
},
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def handle_message(msg):
|
|
261
|
+
"""处理一行 JSON-RPC 消息,返回响应 dict;通知返回 None。"""
|
|
262
|
+
if not isinstance(msg, dict) or msg.get("jsonrpc") != "2.0":
|
|
263
|
+
rid = msg.get("id") if isinstance(msg, dict) else None
|
|
264
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32600, "message": "invalid request"}}
|
|
265
|
+
method = msg.get("method")
|
|
266
|
+
rid = msg.get("id")
|
|
267
|
+
if rid is None: # JSON-RPC 通知(无 id)不响应
|
|
268
|
+
return None
|
|
269
|
+
if method is None:
|
|
270
|
+
return None
|
|
271
|
+
params = msg.get("params") or {}
|
|
272
|
+
|
|
273
|
+
if method == "initialize":
|
|
274
|
+
return {
|
|
275
|
+
"jsonrpc": "2.0", "id": rid,
|
|
276
|
+
"result": {
|
|
277
|
+
"protocolVersion": MCP_PROTOCOL,
|
|
278
|
+
"capabilities": {"tools": {}},
|
|
279
|
+
"serverInfo": {"name": TOOL_NAME, "version": VERSION},
|
|
280
|
+
},
|
|
281
|
+
}
|
|
282
|
+
if method == "ping":
|
|
283
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {}}
|
|
284
|
+
if method == "tools/list":
|
|
285
|
+
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": mcp_tools()}}
|
|
286
|
+
if method == "tools/call":
|
|
287
|
+
name = params.get("name")
|
|
288
|
+
arguments = params.get("arguments") or {}
|
|
289
|
+
handler = TOOL_HANDLERS.get(name)
|
|
290
|
+
if not handler:
|
|
291
|
+
return {
|
|
292
|
+
"jsonrpc": "2.0", "id": rid,
|
|
293
|
+
"result": {"content": [{"type": "text", "text": "未知工具: %s" % name}], "isError": True},
|
|
294
|
+
}
|
|
295
|
+
try:
|
|
296
|
+
return {
|
|
297
|
+
"jsonrpc": "2.0", "id": rid,
|
|
298
|
+
"result": handler(arguments),
|
|
299
|
+
}
|
|
300
|
+
except Exception as e: # noqa: BLE001
|
|
301
|
+
return {
|
|
302
|
+
"jsonrpc": "2.0", "id": rid,
|
|
303
|
+
"result": {"content": [{"type": "text", "text": "工具执行异常:%s" % e}], "isError": True},
|
|
304
|
+
}
|
|
305
|
+
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": "Method not found: " + str(method)}}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def main():
|
|
309
|
+
"""stdio 主循环:读行 -> JSON-RPC -> 响应行。"""
|
|
310
|
+
try:
|
|
311
|
+
sys.stdin.reconfigure(encoding="utf-8")
|
|
312
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
313
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
314
|
+
except Exception: # noqa: BLE001
|
|
315
|
+
pass
|
|
316
|
+
for line in sys.stdin:
|
|
317
|
+
line = line.strip()
|
|
318
|
+
if not line:
|
|
319
|
+
continue
|
|
320
|
+
try:
|
|
321
|
+
msg = json.loads(line)
|
|
322
|
+
except json.JSONDecodeError:
|
|
323
|
+
sys.stdout.write(json.dumps(
|
|
324
|
+
{"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": "parse error"}},
|
|
325
|
+
ensure_ascii=False) + "\n")
|
|
326
|
+
sys.stdout.flush()
|
|
327
|
+
continue
|
|
328
|
+
resp = handle_message(msg)
|
|
329
|
+
if resp is not None:
|
|
330
|
+
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
|
|
331
|
+
sys.stdout.flush()
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
if __name__ == "__main__":
|
|
335
|
+
main()
|