loongclaw-devkit 0.1.0__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.
- loongclaw_devkit/__init__.py +5 -0
- loongclaw_devkit/__main__.py +5 -0
- loongclaw_devkit/publish.py +660 -0
- loongclaw_devkit/server.py +288 -0
- loongclaw_devkit/templates/AGENTS_template.md +112 -0
- loongclaw_devkit/templates/requirements_template.txt +1 -0
- loongclaw_devkit/templates/server_template.py +40 -0
- loongclaw_devkit-0.1.0.dist-info/METADATA +105 -0
- loongclaw_devkit-0.1.0.dist-info/RECORD +11 -0
- loongclaw_devkit-0.1.0.dist-info/WHEEL +4 -0
- loongclaw_devkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,660 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""LoongClaw MCP 一键发布工具
|
|
3
|
+
|
|
4
|
+
交互模式(人类用):
|
|
5
|
+
python publish.py
|
|
6
|
+
|
|
7
|
+
自动模式(AI 用):
|
|
8
|
+
python publish.py --auto --id my-mcp --version 1.0.0 --access public --json-output
|
|
9
|
+
|
|
10
|
+
首次运行后配置保存到 .publish.json,后续 --auto 自动读取。
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import zipfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
# ── 常量 ──────────────────────────────────────────────────
|
|
26
|
+
CLOUD_API_BASE = "https://yun.loongclaw.com"
|
|
27
|
+
UPLOAD_ENDPOINT = "/v1/store/upload"
|
|
28
|
+
ENTRYPOINT = "server.py" # 入口文件,始终明文
|
|
29
|
+
CONFIG_FILE = ".publish.json"
|
|
30
|
+
IGNORE_PATTERNS = {
|
|
31
|
+
"__pycache__", ".pytest_cache", ".ruff_cache", ".git", ".venv",
|
|
32
|
+
"logs", "cache", ".coverage", "*.pyc", "*.pyo", ".publish.json",
|
|
33
|
+
"publish.py", "setup.py", "build", "dist", "*.egg-info",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
# ── 工具函数 ──────────────────────────────────────────────
|
|
38
|
+
|
|
39
|
+
def should_skip(name: str) -> bool:
|
|
40
|
+
if name in IGNORE_PATTERNS:
|
|
41
|
+
return True
|
|
42
|
+
return any(
|
|
43
|
+
name.endswith(p[1:]) for p in IGNORE_PATTERNS if p.startswith("*")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def print_step(n: int, total: int, msg: str) -> None:
|
|
48
|
+
print(f"\n[{n}/{total}] {msg}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def detect_project_info(project_dir: Path) -> dict:
|
|
52
|
+
"""从 server.py 的 FastMCP 实例中提取插件信息。"""
|
|
53
|
+
entry = project_dir / ENTRYPOINT
|
|
54
|
+
if not entry.exists():
|
|
55
|
+
return {}
|
|
56
|
+
|
|
57
|
+
text = entry.read_text(encoding="utf-8", errors="replace")
|
|
58
|
+
info: dict = {}
|
|
59
|
+
|
|
60
|
+
# FastMCP("name", ...) 或 FastMCP(name="...")
|
|
61
|
+
m = re.search(r'FastMCP\(\s*["\']([^"\']+)["\']', text)
|
|
62
|
+
if m:
|
|
63
|
+
info["id"] = m.group(1)
|
|
64
|
+
|
|
65
|
+
# instructions=("...") 或 description="..."
|
|
66
|
+
m = re.search(r'(?:instructions|description)\s*=\s*["\'\(]+(.*?)[\)"\']', text, re.DOTALL)
|
|
67
|
+
if m:
|
|
68
|
+
info["description"] = " ".join(m.group(1).split())[:200]
|
|
69
|
+
|
|
70
|
+
return info
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def find_py_files(project_dir: Path) -> list[Path]:
|
|
74
|
+
"""递归查找所有 .py 文件,排除忽略项。"""
|
|
75
|
+
results = []
|
|
76
|
+
for p in sorted(project_dir.rglob("*.py")):
|
|
77
|
+
if any(should_skip(part) for part in p.relative_to(project_dir).parts):
|
|
78
|
+
continue
|
|
79
|
+
results.append(p)
|
|
80
|
+
return results
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def validate_project(project_dir: Path) -> list[str]:
|
|
84
|
+
"""校验项目结构,返回错误列表。"""
|
|
85
|
+
errors = []
|
|
86
|
+
entry = project_dir / ENTRYPOINT
|
|
87
|
+
if not entry.exists():
|
|
88
|
+
errors.append(f"缺少入口文件 {ENTRYPOINT}")
|
|
89
|
+
else:
|
|
90
|
+
text = entry.read_text(encoding="utf-8", errors="replace")
|
|
91
|
+
if "FastMCP" not in text:
|
|
92
|
+
errors.append(f"{ENTRYPOINT} 中未找到 FastMCP 实例")
|
|
93
|
+
if "mcp.run(" not in text and ".run(" not in text:
|
|
94
|
+
errors.append(f"{ENTRYPOINT} 中未找到 mcp.run() 调用")
|
|
95
|
+
|
|
96
|
+
req = project_dir / "requirements.txt"
|
|
97
|
+
if not req.exists():
|
|
98
|
+
errors.append("缺少 requirements.txt")
|
|
99
|
+
|
|
100
|
+
return errors
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ── 加密(Cython 编译)──────────────────────────────────
|
|
104
|
+
|
|
105
|
+
def encrypt_files(
|
|
106
|
+
project_dir: Path,
|
|
107
|
+
py_files: list[Path],
|
|
108
|
+
skip_encrypt: list[str],
|
|
109
|
+
staging_dir: Path,
|
|
110
|
+
) -> tuple[list[str], list[str]]:
|
|
111
|
+
"""编译 .py → .so/.pyd,返回 (加密文件列表, 明文文件列表)。"""
|
|
112
|
+
always_plain = {ENTRYPOINT, "publish.py", "setup.py", "__init__.py"}
|
|
113
|
+
skip_set = set(skip_encrypt) | always_plain
|
|
114
|
+
|
|
115
|
+
to_encrypt = []
|
|
116
|
+
plain_files = []
|
|
117
|
+
|
|
118
|
+
for f in py_files:
|
|
119
|
+
rel = str(f.relative_to(project_dir))
|
|
120
|
+
name = f.name
|
|
121
|
+
if name in skip_set or rel in skip_set:
|
|
122
|
+
plain_files.append(rel)
|
|
123
|
+
else:
|
|
124
|
+
to_encrypt.append(f)
|
|
125
|
+
|
|
126
|
+
if not to_encrypt:
|
|
127
|
+
print(" ℹ 无需加密的文件")
|
|
128
|
+
return [], [str(f.relative_to(project_dir)) for f in py_files]
|
|
129
|
+
|
|
130
|
+
# 检查 Cython 是否安装
|
|
131
|
+
try:
|
|
132
|
+
import Cython # noqa: F401
|
|
133
|
+
except ImportError:
|
|
134
|
+
print(" ⚠ Cython 未安装,正在安装...")
|
|
135
|
+
subprocess.check_call(
|
|
136
|
+
[sys.executable, "-m", "pip", "install", "cython", "-q"],
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
encrypted_names = []
|
|
140
|
+
for f in to_encrypt:
|
|
141
|
+
rel = f.relative_to(project_dir)
|
|
142
|
+
module_name = f.stem
|
|
143
|
+
work_dir = staging_dir / rel.parent
|
|
144
|
+
work_dir.mkdir(parents=True, exist_ok=True)
|
|
145
|
+
|
|
146
|
+
# 复制为 .pyx
|
|
147
|
+
pyx_path = work_dir / f"{module_name}.pyx"
|
|
148
|
+
shutil.copy2(f, pyx_path)
|
|
149
|
+
|
|
150
|
+
# 编译
|
|
151
|
+
print(f" 🔒 编译 {rel}...")
|
|
152
|
+
setup_code = (
|
|
153
|
+
"from setuptools import setup\n"
|
|
154
|
+
"from Cython.Build import cythonize\n"
|
|
155
|
+
f"setup(ext_modules=cythonize('{module_name}.pyx', "
|
|
156
|
+
"compiler_directives={'language_level': '3'}))\n"
|
|
157
|
+
)
|
|
158
|
+
setup_file = work_dir / "_setup_tmp.py"
|
|
159
|
+
setup_file.write_text(setup_code)
|
|
160
|
+
|
|
161
|
+
result = subprocess.run(
|
|
162
|
+
[sys.executable, str(setup_file), "build_ext", "--inplace"],
|
|
163
|
+
cwd=str(work_dir),
|
|
164
|
+
capture_output=True,
|
|
165
|
+
text=True,
|
|
166
|
+
)
|
|
167
|
+
if result.returncode != 0:
|
|
168
|
+
print(f" ❌ 编译失败: {rel}")
|
|
169
|
+
print(f" {result.stderr[:500]}")
|
|
170
|
+
# 回退:保留明文
|
|
171
|
+
plain_files.append(str(rel))
|
|
172
|
+
continue
|
|
173
|
+
|
|
174
|
+
# 收集编译产物
|
|
175
|
+
so_files = list(work_dir.glob(f"{module_name}.cpython-*.so")) + \
|
|
176
|
+
list(work_dir.glob(f"{module_name}.cp*-*.pyd"))
|
|
177
|
+
if so_files:
|
|
178
|
+
encrypted_names.append(str(rel))
|
|
179
|
+
else:
|
|
180
|
+
print(f" ⚠ 未找到编译产物: {rel},保留明文")
|
|
181
|
+
plain_files.append(str(rel))
|
|
182
|
+
|
|
183
|
+
# 清理中间文件
|
|
184
|
+
for tmp in [pyx_path, setup_file]:
|
|
185
|
+
tmp.unlink(missing_ok=True)
|
|
186
|
+
for c_file in work_dir.glob(f"{module_name}.c"):
|
|
187
|
+
c_file.unlink(missing_ok=True)
|
|
188
|
+
for build_dir in work_dir.glob("build"):
|
|
189
|
+
shutil.rmtree(build_dir, ignore_errors=True)
|
|
190
|
+
|
|
191
|
+
return encrypted_names, plain_files
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
# ── 打包 ─────────────────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
def package_project(
|
|
197
|
+
project_dir: Path,
|
|
198
|
+
staging_dir: Path,
|
|
199
|
+
plain_files: list[str],
|
|
200
|
+
) -> Path:
|
|
201
|
+
"""收集明文文件 + 编译产物 + vendor wheels → project.zip"""
|
|
202
|
+
# 复制明文文件
|
|
203
|
+
for rel in plain_files:
|
|
204
|
+
src = project_dir / rel
|
|
205
|
+
dst = staging_dir / rel
|
|
206
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
207
|
+
if src.exists():
|
|
208
|
+
shutil.copy2(src, dst)
|
|
209
|
+
|
|
210
|
+
# 复制非 .py 文件(配置、模板等)
|
|
211
|
+
for f in sorted(project_dir.rglob("*")):
|
|
212
|
+
if f.is_dir():
|
|
213
|
+
continue
|
|
214
|
+
rel = f.relative_to(project_dir)
|
|
215
|
+
if any(should_skip(part) for part in rel.parts):
|
|
216
|
+
continue
|
|
217
|
+
if f.suffix == ".py":
|
|
218
|
+
continue # .py 已处理
|
|
219
|
+
dst = staging_dir / rel
|
|
220
|
+
if not dst.exists():
|
|
221
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
shutil.copy2(f, dst)
|
|
223
|
+
|
|
224
|
+
# requirements.txt → vendor wheels
|
|
225
|
+
req = staging_dir / "requirements.txt"
|
|
226
|
+
if req.exists():
|
|
227
|
+
vendor_dir = staging_dir / "_vendor"
|
|
228
|
+
vendor_dir.mkdir(exist_ok=True)
|
|
229
|
+
print(" 📦 下载离线 wheel 包...")
|
|
230
|
+
result = subprocess.run(
|
|
231
|
+
[
|
|
232
|
+
sys.executable, "-m", "pip", "download",
|
|
233
|
+
"-r", str(req), "-d", str(vendor_dir),
|
|
234
|
+
"--only-binary=:all:",
|
|
235
|
+
],
|
|
236
|
+
capture_output=True, text=True,
|
|
237
|
+
)
|
|
238
|
+
if result.returncode != 0:
|
|
239
|
+
# 尝试清华镜像
|
|
240
|
+
subprocess.run(
|
|
241
|
+
[
|
|
242
|
+
sys.executable, "-m", "pip", "download",
|
|
243
|
+
"-r", str(req), "-d", str(vendor_dir),
|
|
244
|
+
"--only-binary=:all:",
|
|
245
|
+
"-i", "https://pypi.tuna.tsinghua.edu.cn/simple",
|
|
246
|
+
"--trusted-host", "pypi.tuna.tsinghua.edu.cn",
|
|
247
|
+
],
|
|
248
|
+
capture_output=True, text=True,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
# 打 zip
|
|
252
|
+
zip_path = project_dir / "project.zip"
|
|
253
|
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
|
254
|
+
for root, dirs, files in os.walk(staging_dir):
|
|
255
|
+
dirs[:] = [d for d in dirs if not should_skip(d)]
|
|
256
|
+
for fname in sorted(files):
|
|
257
|
+
if should_skip(fname):
|
|
258
|
+
continue
|
|
259
|
+
full = Path(root) / fname
|
|
260
|
+
arcname = full.relative_to(staging_dir)
|
|
261
|
+
zf.write(full, arcname)
|
|
262
|
+
|
|
263
|
+
return zip_path
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
# ── Manifest 生成 ────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
def generate_manifest(
|
|
269
|
+
project_dir: Path,
|
|
270
|
+
staging_dir: Path,
|
|
271
|
+
config: dict,
|
|
272
|
+
) -> dict:
|
|
273
|
+
"""生成 manifest.json。"""
|
|
274
|
+
# 收集 staging 中的所有文件
|
|
275
|
+
all_files = []
|
|
276
|
+
for f in sorted(staging_dir.rglob("*")):
|
|
277
|
+
if f.is_dir():
|
|
278
|
+
continue
|
|
279
|
+
rel = str(f.relative_to(staging_dir))
|
|
280
|
+
if not should_skip(f.name):
|
|
281
|
+
all_files.append(rel)
|
|
282
|
+
|
|
283
|
+
# 检测 configFields
|
|
284
|
+
config_fields = []
|
|
285
|
+
entry = project_dir / ENTRYPOINT
|
|
286
|
+
if entry.exists():
|
|
287
|
+
text = entry.read_text(encoding="utf-8", errors="replace")
|
|
288
|
+
for m in re.finditer(
|
|
289
|
+
r'os\.environ\.get\(["\'](\w+)["\'](?:,\s*["\']([^"\']*)["\'])?\)',
|
|
290
|
+
text,
|
|
291
|
+
):
|
|
292
|
+
key = m.group(1)
|
|
293
|
+
default = m.group(2) or ""
|
|
294
|
+
if key not in ("PATH", "HOME", "USER", "LANG"):
|
|
295
|
+
config_fields.append({
|
|
296
|
+
"key": key,
|
|
297
|
+
"label": key.replace("_", " ").title(),
|
|
298
|
+
"default": default,
|
|
299
|
+
"required": default == "",
|
|
300
|
+
})
|
|
301
|
+
|
|
302
|
+
manifest = {
|
|
303
|
+
"id": config["id"],
|
|
304
|
+
"name": config.get("name", config["id"]),
|
|
305
|
+
"description": config.get("description", ""),
|
|
306
|
+
"version": config["version"],
|
|
307
|
+
"author": config.get("author", ""),
|
|
308
|
+
"icon": config.get("icon", "🔧"),
|
|
309
|
+
"runtime": "python",
|
|
310
|
+
"entrypoint": ENTRYPOINT,
|
|
311
|
+
"files": [],
|
|
312
|
+
"env": {},
|
|
313
|
+
"configFields": config_fields,
|
|
314
|
+
"sourceArchive": "project.zip",
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
# 写到 staging
|
|
318
|
+
manifest_path = staging_dir / "manifest.json"
|
|
319
|
+
manifest_path.write_text(
|
|
320
|
+
json.dumps(manifest, ensure_ascii=False, indent=2),
|
|
321
|
+
encoding="utf-8",
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
return manifest
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
# ── 上传 ──────────────────────────────────────────────────
|
|
328
|
+
|
|
329
|
+
def upload_to_cloud(
|
|
330
|
+
project_dir: Path,
|
|
331
|
+
config: dict,
|
|
332
|
+
manifest: dict,
|
|
333
|
+
) -> dict:
|
|
334
|
+
"""上传到 LoongClaw Cloud API。"""
|
|
335
|
+
import urllib.request
|
|
336
|
+
import urllib.error
|
|
337
|
+
|
|
338
|
+
token = config.get("token", "")
|
|
339
|
+
if not token:
|
|
340
|
+
token = os.environ.get("LOONGCLAW_TOKEN", "")
|
|
341
|
+
if not token:
|
|
342
|
+
return {"status": "error", "step": "upload",
|
|
343
|
+
"error": "缺少认证 token。设置环境变量 LOONGCLAW_TOKEN 或在 .publish.json 中配置 token",
|
|
344
|
+
"fix": "运行 python publish.py --reconfigure 设置 token"}
|
|
345
|
+
|
|
346
|
+
# 构造上传请求
|
|
347
|
+
zip_path = project_dir / "project.zip"
|
|
348
|
+
if not zip_path.exists():
|
|
349
|
+
return {"status": "error", "step": "upload",
|
|
350
|
+
"error": "project.zip 不存在",
|
|
351
|
+
"fix": "检查打包步骤是否成功"}
|
|
352
|
+
|
|
353
|
+
manifest_path = project_dir / "_staging" / "manifest.json"
|
|
354
|
+
if not manifest_path.exists():
|
|
355
|
+
manifest_path = project_dir / "manifest.json"
|
|
356
|
+
|
|
357
|
+
# multipart/form-data 上传
|
|
358
|
+
boundary = "----LoongClawUpload"
|
|
359
|
+
body = b""
|
|
360
|
+
|
|
361
|
+
# manifest.json 部分
|
|
362
|
+
body += f"--{boundary}\r\n".encode()
|
|
363
|
+
body += b'Content-Disposition: form-data; name="manifest"; filename="manifest.json"\r\n'
|
|
364
|
+
body += b"Content-Type: application/json\r\n\r\n"
|
|
365
|
+
body += json.dumps(manifest, ensure_ascii=False).encode("utf-8")
|
|
366
|
+
body += b"\r\n"
|
|
367
|
+
|
|
368
|
+
# project.zip 部分
|
|
369
|
+
body += f"--{boundary}\r\n".encode()
|
|
370
|
+
body += b'Content-Disposition: form-data; name="archive"; filename="project.zip"\r\n'
|
|
371
|
+
body += b"Content-Type: application/zip\r\n\r\n"
|
|
372
|
+
body += zip_path.read_bytes()
|
|
373
|
+
body += b"\r\n"
|
|
374
|
+
|
|
375
|
+
# accessType 部分
|
|
376
|
+
body += f"--{boundary}\r\n".encode()
|
|
377
|
+
body += b'Content-Disposition: form-data; name="accessType"\r\n\r\n'
|
|
378
|
+
body += config.get("access", "public").encode()
|
|
379
|
+
body += b"\r\n"
|
|
380
|
+
|
|
381
|
+
body += f"--{boundary}--\r\n".encode()
|
|
382
|
+
|
|
383
|
+
url = f"{CLOUD_API_BASE}{UPLOAD_ENDPOINT}"
|
|
384
|
+
req = urllib.request.Request(
|
|
385
|
+
url,
|
|
386
|
+
data=body,
|
|
387
|
+
headers={
|
|
388
|
+
"Authorization": f"Bearer {token}",
|
|
389
|
+
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
|
390
|
+
},
|
|
391
|
+
method="POST",
|
|
392
|
+
)
|
|
393
|
+
|
|
394
|
+
try:
|
|
395
|
+
with urllib.request.urlopen(req) as resp:
|
|
396
|
+
result = json.loads(resp.read().decode("utf-8"))
|
|
397
|
+
return {"status": "success", **result}
|
|
398
|
+
except urllib.error.HTTPError as e:
|
|
399
|
+
err_body = e.read().decode("utf-8", errors="replace")
|
|
400
|
+
return {"status": "error", "step": "upload",
|
|
401
|
+
"error": f"HTTP {e.code}: {err_body[:300]}",
|
|
402
|
+
"fix": "检查 token 是否有效,或联系管理员"}
|
|
403
|
+
except urllib.error.URLError as e:
|
|
404
|
+
return {"status": "error", "step": "upload",
|
|
405
|
+
"error": f"网络错误: {e.reason}",
|
|
406
|
+
"fix": "检查网络连接和 CLOUD_API_BASE 地址"}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
# ── 配置管理 ─────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
def load_config(project_dir: Path) -> dict | None:
|
|
412
|
+
cfg_path = project_dir / CONFIG_FILE
|
|
413
|
+
if cfg_path.exists():
|
|
414
|
+
try:
|
|
415
|
+
return json.loads(cfg_path.read_text(encoding="utf-8"))
|
|
416
|
+
except (json.JSONDecodeError, OSError):
|
|
417
|
+
pass
|
|
418
|
+
return None
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def save_config(project_dir: Path, config: dict) -> None:
|
|
422
|
+
cfg_path = project_dir / CONFIG_FILE
|
|
423
|
+
# 不保存 token 到配置文件(安全)
|
|
424
|
+
safe = {k: v for k, v in config.items() if k != "token"}
|
|
425
|
+
cfg_path.write_text(
|
|
426
|
+
json.dumps(safe, ensure_ascii=False, indent=2),
|
|
427
|
+
encoding="utf-8",
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
# ── 交互式配置 ───────────────────────────────────────────
|
|
432
|
+
|
|
433
|
+
def interactive_config(project_dir: Path, detected: dict) -> dict:
|
|
434
|
+
"""交互式引导配置。"""
|
|
435
|
+
print("\n═══ LoongClaw MCP 发布工具 ═══\n")
|
|
436
|
+
|
|
437
|
+
config: dict = {}
|
|
438
|
+
|
|
439
|
+
# ID
|
|
440
|
+
default_id = detected.get("id", project_dir.name)
|
|
441
|
+
val = input(f"▶ 插件 ID [{default_id}]: ").strip()
|
|
442
|
+
config["id"] = val if val else default_id
|
|
443
|
+
|
|
444
|
+
# 名称
|
|
445
|
+
default_name = config["id"]
|
|
446
|
+
val = input(f"▶ 插件名称 [{default_name}]: ").strip()
|
|
447
|
+
config["name"] = val if val else default_name
|
|
448
|
+
|
|
449
|
+
# 描述
|
|
450
|
+
default_desc = detected.get("description", "")
|
|
451
|
+
if default_desc:
|
|
452
|
+
print(f" (自动检测到描述:{default_desc[:60]}...)")
|
|
453
|
+
val = input(f"▶ 插件描述 [{default_desc[:60] or '无'}]: ").strip()
|
|
454
|
+
config["description"] = val if val else default_desc
|
|
455
|
+
|
|
456
|
+
# 版本
|
|
457
|
+
val = input("▶ 版本号 [1.0.0]: ").strip()
|
|
458
|
+
config["version"] = val if val else "1.0.0"
|
|
459
|
+
|
|
460
|
+
# 作者
|
|
461
|
+
val = input("▶ 作者名: ").strip()
|
|
462
|
+
config["author"] = val
|
|
463
|
+
|
|
464
|
+
# 图标
|
|
465
|
+
val = input("▶ 图标 emoji [🔧]: ").strip()
|
|
466
|
+
config["icon"] = val if val else "🔧"
|
|
467
|
+
|
|
468
|
+
# 不加密文件
|
|
469
|
+
py_files = find_py_files(project_dir)
|
|
470
|
+
encryptable = [
|
|
471
|
+
str(f.relative_to(project_dir)) for f in py_files
|
|
472
|
+
if f.name not in {ENTRYPOINT, "publish.py", "setup.py", "__init__.py"}
|
|
473
|
+
]
|
|
474
|
+
if encryptable:
|
|
475
|
+
print(f"\n▶ 以下 {len(encryptable)} 个 .py 文件将被加密编译:")
|
|
476
|
+
for name in encryptable:
|
|
477
|
+
print(f" {name}")
|
|
478
|
+
val = input(" 不需要加密的文件(逗号分隔,留空=全加密): ").strip()
|
|
479
|
+
config["skipEncrypt"] = [s.strip() for s in val.split(",") if s.strip()] if val else []
|
|
480
|
+
else:
|
|
481
|
+
config["skipEncrypt"] = []
|
|
482
|
+
|
|
483
|
+
# 访问类型
|
|
484
|
+
print("\n▶ 访问类型:")
|
|
485
|
+
print(" [1] 公开(所有用户免费使用)")
|
|
486
|
+
print(" [2] 付费(需联系商务开通)")
|
|
487
|
+
val = input(" 选择 [1]: ").strip()
|
|
488
|
+
config["access"] = "private" if val == "2" else "public"
|
|
489
|
+
|
|
490
|
+
# 上传
|
|
491
|
+
val = input("\n▶ 自动上传到 LoongClaw 服务器?[Y/n]: ").strip().lower()
|
|
492
|
+
config["upload"] = val != "n"
|
|
493
|
+
|
|
494
|
+
if config["upload"]:
|
|
495
|
+
val = input("▶ LoongClaw API Token(环境变量 LOONGCLAW_TOKEN 也可): ").strip()
|
|
496
|
+
if val:
|
|
497
|
+
config["token"] = val
|
|
498
|
+
|
|
499
|
+
return config
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# ── 主流程 ────────────────────────────────────────────────
|
|
503
|
+
|
|
504
|
+
def run(config: dict, project_dir: Path, json_output: bool = False) -> dict:
|
|
505
|
+
"""执行完整发布流程,返回结果字典。"""
|
|
506
|
+
total_steps = 6 if config.get("upload") else 5
|
|
507
|
+
result: dict = {"status": "success", "steps": {}}
|
|
508
|
+
|
|
509
|
+
# Step 1: 校验
|
|
510
|
+
print_step(1, total_steps, "校验项目结构...")
|
|
511
|
+
errors = validate_project(project_dir)
|
|
512
|
+
if errors:
|
|
513
|
+
result = {
|
|
514
|
+
"status": "error", "step": "validate",
|
|
515
|
+
"errors": errors,
|
|
516
|
+
"fix": "; ".join(errors),
|
|
517
|
+
}
|
|
518
|
+
if json_output:
|
|
519
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
520
|
+
else:
|
|
521
|
+
for e in errors:
|
|
522
|
+
print(f" ❌ {e}")
|
|
523
|
+
return result
|
|
524
|
+
print(" ✅ 项目结构正确")
|
|
525
|
+
result["steps"]["validate"] = "ok"
|
|
526
|
+
|
|
527
|
+
# Step 2: 准备 staging
|
|
528
|
+
staging_dir = project_dir / "_staging"
|
|
529
|
+
if staging_dir.exists():
|
|
530
|
+
shutil.rmtree(staging_dir)
|
|
531
|
+
staging_dir.mkdir()
|
|
532
|
+
|
|
533
|
+
# Step 3: 加密编译
|
|
534
|
+
print_step(2, total_steps, "Cython 加密编译...")
|
|
535
|
+
py_files = find_py_files(project_dir)
|
|
536
|
+
encrypted, plain = encrypt_files(
|
|
537
|
+
project_dir, py_files,
|
|
538
|
+
config.get("skipEncrypt", []),
|
|
539
|
+
staging_dir,
|
|
540
|
+
)
|
|
541
|
+
print(f" ✅ 加密 {len(encrypted)} 个,明文 {len(plain)} 个")
|
|
542
|
+
result["steps"]["encrypt"] = {"encrypted": len(encrypted), "plain": len(plain)}
|
|
543
|
+
|
|
544
|
+
# Step 4: 打包
|
|
545
|
+
print_step(3, total_steps, "打包 project.zip...")
|
|
546
|
+
zip_path = package_project(project_dir, staging_dir, plain)
|
|
547
|
+
size_mb = zip_path.stat().st_size / 1024 / 1024
|
|
548
|
+
print(f" ✅ {zip_path.name} ({size_mb:.1f} MB)")
|
|
549
|
+
result["steps"]["package"] = {"size_mb": round(size_mb, 1)}
|
|
550
|
+
|
|
551
|
+
# Step 5: 生成 manifest
|
|
552
|
+
print_step(4, total_steps, "生成 manifest.json...")
|
|
553
|
+
manifest = generate_manifest(project_dir, staging_dir, config)
|
|
554
|
+
print(f" ✅ {manifest['id']} v{manifest['version']}")
|
|
555
|
+
result["steps"]["manifest"] = manifest
|
|
556
|
+
|
|
557
|
+
# Step 6: 上传(可选)
|
|
558
|
+
if config.get("upload"):
|
|
559
|
+
print_step(5, total_steps, "上传到 LoongClaw 服务器...")
|
|
560
|
+
upload_result = upload_to_cloud(project_dir, config, manifest)
|
|
561
|
+
if upload_result["status"] == "error":
|
|
562
|
+
result["status"] = "error"
|
|
563
|
+
result["step"] = "upload"
|
|
564
|
+
result["error"] = upload_result["error"]
|
|
565
|
+
result["fix"] = upload_result.get("fix", "")
|
|
566
|
+
if json_output:
|
|
567
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
568
|
+
else:
|
|
569
|
+
print(f" ❌ {upload_result['error']}")
|
|
570
|
+
# 清理 staging 但保留 zip
|
|
571
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
572
|
+
return result
|
|
573
|
+
print(" ✅ 上传成功")
|
|
574
|
+
result["steps"]["upload"] = "ok"
|
|
575
|
+
|
|
576
|
+
# 保存配置
|
|
577
|
+
save_config(project_dir, config)
|
|
578
|
+
|
|
579
|
+
# 清理
|
|
580
|
+
shutil.rmtree(staging_dir, ignore_errors=True)
|
|
581
|
+
|
|
582
|
+
result["version"] = config["version"]
|
|
583
|
+
result["files_encrypted"] = len(encrypted)
|
|
584
|
+
result["uploaded"] = config.get("upload", False)
|
|
585
|
+
|
|
586
|
+
if json_output:
|
|
587
|
+
print(json.dumps(result, ensure_ascii=False))
|
|
588
|
+
else:
|
|
589
|
+
print(f"\n{'═' * 50}")
|
|
590
|
+
print(f" 发布完成! {config['id']} v{config['version']}")
|
|
591
|
+
if config.get("access") == "private":
|
|
592
|
+
print(" 📌 访问类型: 付费(需商务开通)")
|
|
593
|
+
else:
|
|
594
|
+
print(" 📌 访问类型: 公开")
|
|
595
|
+
print(f"{'═' * 50}")
|
|
596
|
+
|
|
597
|
+
return result
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def main() -> None:
|
|
601
|
+
parser = argparse.ArgumentParser(description="LoongClaw MCP 一键发布工具")
|
|
602
|
+
parser.add_argument("--auto", action="store_true",
|
|
603
|
+
help="自动模式,使用 .publish.json 或命令行参数")
|
|
604
|
+
parser.add_argument("--reconfigure", action="store_true",
|
|
605
|
+
help="重新配置(忽略 .publish.json)")
|
|
606
|
+
parser.add_argument("--json-output", action="store_true",
|
|
607
|
+
help="JSON 格式输出(适合 AI 解析)")
|
|
608
|
+
parser.add_argument("--id", type=str, help="插件 ID")
|
|
609
|
+
parser.add_argument("--name", type=str, help="插件名称")
|
|
610
|
+
parser.add_argument("--version", type=str, help="版本号")
|
|
611
|
+
parser.add_argument("--access", choices=["public", "private"], help="访问类型")
|
|
612
|
+
parser.add_argument("--skip-encrypt", type=str, default="",
|
|
613
|
+
help="不加密的文件(逗号分隔)")
|
|
614
|
+
parser.add_argument("--upload", action="store_true", help="上传到服务器")
|
|
615
|
+
parser.add_argument("--no-upload", action="store_true", help="不上传")
|
|
616
|
+
parser.add_argument("--token", type=str, help="LoongClaw API Token")
|
|
617
|
+
parser.add_argument("--dir", type=str, default=".",
|
|
618
|
+
help="项目目录(默认当前目录)")
|
|
619
|
+
args = parser.parse_args()
|
|
620
|
+
|
|
621
|
+
project_dir = Path(args.dir).resolve()
|
|
622
|
+
json_output = args.json_output
|
|
623
|
+
|
|
624
|
+
if args.auto:
|
|
625
|
+
# 自动模式:优先命令行参数 > .publish.json > 自动检测
|
|
626
|
+
saved = load_config(project_dir) or {}
|
|
627
|
+
detected = detect_project_info(project_dir)
|
|
628
|
+
|
|
629
|
+
config = {
|
|
630
|
+
"id": args.id or saved.get("id") or detected.get("id") or project_dir.name,
|
|
631
|
+
"name": args.name or saved.get("name") or saved.get("id", project_dir.name),
|
|
632
|
+
"version": args.version or saved.get("version", "1.0.0"),
|
|
633
|
+
"description": saved.get("description") or detected.get("description", ""),
|
|
634
|
+
"author": saved.get("author", ""),
|
|
635
|
+
"icon": saved.get("icon", "🔧"),
|
|
636
|
+
"access": args.access or saved.get("access", "public"),
|
|
637
|
+
"skipEncrypt": (
|
|
638
|
+
[s.strip() for s in args.skip_encrypt.split(",") if s.strip()]
|
|
639
|
+
if args.skip_encrypt
|
|
640
|
+
else saved.get("skipEncrypt", [])
|
|
641
|
+
),
|
|
642
|
+
"upload": args.upload or (not args.no_upload and saved.get("upload", False)),
|
|
643
|
+
"token": args.token or os.environ.get("LOONGCLAW_TOKEN", ""),
|
|
644
|
+
}
|
|
645
|
+
elif args.reconfigure or not load_config(project_dir):
|
|
646
|
+
# 交互模式
|
|
647
|
+
detected = detect_project_info(project_dir)
|
|
648
|
+
config = interactive_config(project_dir, detected)
|
|
649
|
+
else:
|
|
650
|
+
# 有配置文件 → 直接用
|
|
651
|
+
config = load_config(project_dir) or {}
|
|
652
|
+
if not config.get("id"):
|
|
653
|
+
detected = detect_project_info(project_dir)
|
|
654
|
+
config = interactive_config(project_dir, detected)
|
|
655
|
+
|
|
656
|
+
run(config, project_dir, json_output)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
if __name__ == "__main__":
|
|
660
|
+
main()
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
"""LoongClaw DevKit MCP Server
|
|
2
|
+
|
|
3
|
+
提供 3 个工具,让 AI 助手直接完成 MCP 插件的创建、发布、状态查看。
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from mcp.server.fastmcp import FastMCP
|
|
16
|
+
|
|
17
|
+
# 包目录(pip install 后 publish.py 和 templates/ 在同一目录下)
|
|
18
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
19
|
+
PUBLISH_SCRIPT = PACKAGE_DIR / "publish.py"
|
|
20
|
+
TEMPLATES_DIR = PACKAGE_DIR / "templates"
|
|
21
|
+
|
|
22
|
+
mcp = FastMCP(
|
|
23
|
+
"loongclaw-devkit",
|
|
24
|
+
instructions=(
|
|
25
|
+
"LoongClaw MCP 开发者工具包。"
|
|
26
|
+
"帮助开发者创建、加密打包、发布 MCP 插件到 LoongClaw 商店。"
|
|
27
|
+
"一条命令完成整个流程,无需手动操作。"
|
|
28
|
+
),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@mcp.tool()
|
|
33
|
+
def create_mcp_project(
|
|
34
|
+
project_dir: str,
|
|
35
|
+
plugin_id: str,
|
|
36
|
+
plugin_name: str = "",
|
|
37
|
+
description: str = "",
|
|
38
|
+
) -> str:
|
|
39
|
+
"""在指定目录创建一个新的 LoongClaw MCP 插件项目。
|
|
40
|
+
|
|
41
|
+
自动生成 server.py、requirements.txt、AGENTS.md、publish.py 等文件。
|
|
42
|
+
创建完成后可直接开始编写工具函数。
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
project_dir: 项目目录的绝对路径(会自动创建)
|
|
46
|
+
plugin_id: 插件 ID(字母数字和连字符,如 my-plugin)
|
|
47
|
+
plugin_name: 插件显示名称(留空则用 plugin_id)
|
|
48
|
+
description: 插件描述
|
|
49
|
+
"""
|
|
50
|
+
target = Path(project_dir).resolve()
|
|
51
|
+
|
|
52
|
+
if target.exists() and any(target.iterdir()):
|
|
53
|
+
return json.dumps({
|
|
54
|
+
"status": "error",
|
|
55
|
+
"error": f"目录 {target} 已存在且非空",
|
|
56
|
+
"fix": "选择一个空目录或不存在的目录",
|
|
57
|
+
}, ensure_ascii=False)
|
|
58
|
+
|
|
59
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
60
|
+
|
|
61
|
+
name = plugin_name or plugin_id
|
|
62
|
+
|
|
63
|
+
# 复制模板文件
|
|
64
|
+
server_template = TEMPLATES_DIR / "server_template.py"
|
|
65
|
+
if server_template.exists():
|
|
66
|
+
content = server_template.read_text(encoding="utf-8")
|
|
67
|
+
content = content.replace("{{PLUGIN_ID}}", plugin_id)
|
|
68
|
+
content = content.replace("{{PLUGIN_NAME}}", name)
|
|
69
|
+
content = content.replace("{{PLUGIN_DESCRIPTION}}", description)
|
|
70
|
+
(target / "server.py").write_text(content, encoding="utf-8")
|
|
71
|
+
|
|
72
|
+
agents_template = TEMPLATES_DIR / "AGENTS_template.md"
|
|
73
|
+
if agents_template.exists():
|
|
74
|
+
shutil.copy2(agents_template, target / "AGENTS.md")
|
|
75
|
+
|
|
76
|
+
req_template = TEMPLATES_DIR / "requirements_template.txt"
|
|
77
|
+
if req_template.exists():
|
|
78
|
+
shutil.copy2(req_template, target / "requirements.txt")
|
|
79
|
+
|
|
80
|
+
# 复制 publish.py
|
|
81
|
+
if PUBLISH_SCRIPT.exists():
|
|
82
|
+
shutil.copy2(PUBLISH_SCRIPT, target / "publish.py")
|
|
83
|
+
|
|
84
|
+
# 生成初始 .publish.json
|
|
85
|
+
config = {
|
|
86
|
+
"id": plugin_id,
|
|
87
|
+
"name": name,
|
|
88
|
+
"description": description,
|
|
89
|
+
"version": "1.0.0",
|
|
90
|
+
"author": "",
|
|
91
|
+
"icon": "🔧",
|
|
92
|
+
"access": "public",
|
|
93
|
+
"skipEncrypt": [],
|
|
94
|
+
"upload": False,
|
|
95
|
+
}
|
|
96
|
+
(target / ".publish.json").write_text(
|
|
97
|
+
json.dumps(config, ensure_ascii=False, indent=2),
|
|
98
|
+
encoding="utf-8",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
return json.dumps({
|
|
102
|
+
"status": "success",
|
|
103
|
+
"project_dir": str(target),
|
|
104
|
+
"files_created": ["server.py", "requirements.txt", "AGENTS.md", "publish.py", ".publish.json"],
|
|
105
|
+
"next_steps": [
|
|
106
|
+
f"编辑 {target}/server.py 添加你的工具函数",
|
|
107
|
+
"核心业务逻辑放在 core.py 等独立文件中(自动加密)",
|
|
108
|
+
f"测试: cd {target} && python server.py",
|
|
109
|
+
f"发布: cd {target} && python publish.py",
|
|
110
|
+
],
|
|
111
|
+
}, ensure_ascii=False)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@mcp.tool()
|
|
115
|
+
def publish_mcp(
|
|
116
|
+
project_dir: str,
|
|
117
|
+
version: str = "",
|
|
118
|
+
access: str = "",
|
|
119
|
+
upload: bool = False,
|
|
120
|
+
skip_encrypt: str = "",
|
|
121
|
+
token: str = "",
|
|
122
|
+
) -> str:
|
|
123
|
+
"""一键加密打包并发布 MCP 插件到 LoongClaw 商店。
|
|
124
|
+
|
|
125
|
+
自动完成:校验项目 → Cython 加密编译 → 打包 zip → 生成 manifest → 上传服务器。
|
|
126
|
+
配置保存在 .publish.json 中,后续更新只需传 version。
|
|
127
|
+
|
|
128
|
+
Args:
|
|
129
|
+
project_dir: 项目目录的绝对路径
|
|
130
|
+
version: 版本号(如 1.0.0),留空则读取 .publish.json 中的版本
|
|
131
|
+
access: 访问类型 public 或 private,留空则读取配置
|
|
132
|
+
upload: 是否上传到 LoongClaw 服务器
|
|
133
|
+
skip_encrypt: 不加密的文件列表(逗号分隔),留空则全部加密
|
|
134
|
+
token: LoongClaw API Token(也可用 LOONGCLAW_TOKEN 环境变量)
|
|
135
|
+
"""
|
|
136
|
+
target = Path(project_dir).resolve()
|
|
137
|
+
if not target.exists():
|
|
138
|
+
return json.dumps({
|
|
139
|
+
"status": "error",
|
|
140
|
+
"error": f"目录不存在: {target}",
|
|
141
|
+
"fix": "检查路径是否正确",
|
|
142
|
+
}, ensure_ascii=False)
|
|
143
|
+
|
|
144
|
+
if not PUBLISH_SCRIPT.exists():
|
|
145
|
+
return json.dumps({
|
|
146
|
+
"status": "error",
|
|
147
|
+
"error": "publish.py 未找到",
|
|
148
|
+
"fix": f"确认 loongclaw-devkit 安装完整,预期位置: {PUBLISH_SCRIPT}",
|
|
149
|
+
}, ensure_ascii=False)
|
|
150
|
+
|
|
151
|
+
# 构造命令
|
|
152
|
+
cmd = [
|
|
153
|
+
sys.executable, str(PUBLISH_SCRIPT),
|
|
154
|
+
"--auto", "--json-output",
|
|
155
|
+
"--dir", str(target),
|
|
156
|
+
]
|
|
157
|
+
if version:
|
|
158
|
+
cmd.extend(["--version", version])
|
|
159
|
+
if access:
|
|
160
|
+
cmd.extend(["--access", access])
|
|
161
|
+
if upload:
|
|
162
|
+
cmd.append("--upload")
|
|
163
|
+
else:
|
|
164
|
+
cmd.append("--no-upload")
|
|
165
|
+
if skip_encrypt:
|
|
166
|
+
cmd.extend(["--skip-encrypt", skip_encrypt])
|
|
167
|
+
if token:
|
|
168
|
+
cmd.extend(["--token", token])
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
result = subprocess.run(
|
|
172
|
+
cmd,
|
|
173
|
+
capture_output=True,
|
|
174
|
+
text=True,
|
|
175
|
+
cwd=str(target),
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 尝试从 stdout 提取 JSON(最后一行)
|
|
179
|
+
lines = result.stdout.strip().split("\n")
|
|
180
|
+
for line in reversed(lines):
|
|
181
|
+
line = line.strip()
|
|
182
|
+
if line.startswith("{"):
|
|
183
|
+
try:
|
|
184
|
+
parsed = json.loads(line)
|
|
185
|
+
return json.dumps(parsed, ensure_ascii=False)
|
|
186
|
+
except json.JSONDecodeError:
|
|
187
|
+
continue
|
|
188
|
+
|
|
189
|
+
# 没找到 JSON → 返回原始输出
|
|
190
|
+
return json.dumps({
|
|
191
|
+
"status": "error" if result.returncode != 0 else "unknown",
|
|
192
|
+
"stdout": result.stdout[-2000:] if result.stdout else "",
|
|
193
|
+
"stderr": result.stderr[-2000:] if result.stderr else "",
|
|
194
|
+
"fix": "检查 publish.py 输出",
|
|
195
|
+
}, ensure_ascii=False)
|
|
196
|
+
|
|
197
|
+
except Exception as e:
|
|
198
|
+
return json.dumps({
|
|
199
|
+
"status": "error",
|
|
200
|
+
"error": str(e),
|
|
201
|
+
"fix": "检查 Python 环境和 publish.py 是否可执行",
|
|
202
|
+
}, ensure_ascii=False)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@mcp.tool()
|
|
206
|
+
def check_mcp_status(plugin_id: str = "", project_dir: str = "") -> str:
|
|
207
|
+
"""查看 MCP 插件的当前状态。
|
|
208
|
+
|
|
209
|
+
传入 plugin_id 查询已发布的状态,或传入 project_dir 查看本地项目状态。
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
plugin_id: 插件 ID(查询已发布状态时使用)
|
|
213
|
+
project_dir: 项目目录路径(查看本地项目状态时使用)
|
|
214
|
+
"""
|
|
215
|
+
result: dict = {}
|
|
216
|
+
|
|
217
|
+
if project_dir:
|
|
218
|
+
target = Path(project_dir).resolve()
|
|
219
|
+
if not target.exists():
|
|
220
|
+
return json.dumps({
|
|
221
|
+
"status": "error",
|
|
222
|
+
"error": f"目录不存在: {target}",
|
|
223
|
+
}, ensure_ascii=False)
|
|
224
|
+
|
|
225
|
+
# 读取 .publish.json
|
|
226
|
+
config_path = target / ".publish.json"
|
|
227
|
+
if config_path.exists():
|
|
228
|
+
try:
|
|
229
|
+
config = json.loads(config_path.read_text(encoding="utf-8"))
|
|
230
|
+
result["config"] = config
|
|
231
|
+
except Exception:
|
|
232
|
+
result["config"] = None
|
|
233
|
+
|
|
234
|
+
# 检查项目文件
|
|
235
|
+
result["files"] = {
|
|
236
|
+
"server.py": (target / "server.py").exists(),
|
|
237
|
+
"requirements.txt": (target / "requirements.txt").exists(),
|
|
238
|
+
"publish.py": (target / "publish.py").exists(),
|
|
239
|
+
"AGENTS.md": (target / "AGENTS.md").exists(),
|
|
240
|
+
"project.zip": (target / "project.zip").exists(),
|
|
241
|
+
".publish.json": config_path.exists(),
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
# 统计 .py 文件数量
|
|
245
|
+
py_files = list(target.rglob("*.py"))
|
|
246
|
+
result["py_file_count"] = len(py_files)
|
|
247
|
+
result["status"] = "ok"
|
|
248
|
+
|
|
249
|
+
if plugin_id:
|
|
250
|
+
# 尝试查询 Cloud API(需要 token)
|
|
251
|
+
token = os.environ.get("LOONGCLAW_TOKEN", "")
|
|
252
|
+
if token:
|
|
253
|
+
import urllib.request
|
|
254
|
+
import urllib.error
|
|
255
|
+
url = "https://yun.loongclaw.com/v1/store/mcp/registry.json"
|
|
256
|
+
req = urllib.request.Request(
|
|
257
|
+
url, headers={"Authorization": f"Bearer {token}"},
|
|
258
|
+
)
|
|
259
|
+
try:
|
|
260
|
+
with urllib.request.urlopen(req) as resp:
|
|
261
|
+
registry = json.loads(resp.read().decode("utf-8"))
|
|
262
|
+
servers = registry.get("servers", [])
|
|
263
|
+
for s in servers:
|
|
264
|
+
if s.get("id") == plugin_id:
|
|
265
|
+
result["published"] = s
|
|
266
|
+
break
|
|
267
|
+
if "published" not in result:
|
|
268
|
+
result["published"] = None
|
|
269
|
+
result["message"] = f"'{plugin_id}' 未在商店中找到"
|
|
270
|
+
except Exception as e:
|
|
271
|
+
result["registry_error"] = str(e)
|
|
272
|
+
else:
|
|
273
|
+
result["registry_note"] = "设置 LOONGCLAW_TOKEN 环境变量可查询已发布状态"
|
|
274
|
+
|
|
275
|
+
result["status"] = result.get("status", "ok")
|
|
276
|
+
|
|
277
|
+
if not plugin_id and not project_dir:
|
|
278
|
+
return json.dumps({
|
|
279
|
+
"status": "error",
|
|
280
|
+
"error": "请至少提供 plugin_id 或 project_dir",
|
|
281
|
+
"fix": "传入要查询的插件 ID 或项目目录路径",
|
|
282
|
+
}, ensure_ascii=False)
|
|
283
|
+
|
|
284
|
+
return json.dumps(result, ensure_ascii=False, indent=2)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def main():
|
|
288
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# LoongClaw MCP 插件开发指南
|
|
2
|
+
|
|
3
|
+
## 你正在开发什么
|
|
4
|
+
|
|
5
|
+
这是一个 LoongClaw MCP 插件项目。MCP(Model Context Protocol)是让 AI 助手调用外部工具的标准协议。
|
|
6
|
+
你写的每个 `@mcp.tool()` 函数都会变成 AI 可以调用的工具。
|
|
7
|
+
|
|
8
|
+
## 项目结构
|
|
9
|
+
|
|
10
|
+
```
|
|
11
|
+
server.py ← MCP 入口文件(必须保持明文,不要在这里写核心逻辑)
|
|
12
|
+
core.py ← 核心业务逻辑(发布时自动加密编译成 .so/.pyd)
|
|
13
|
+
requirements.txt ← Python 依赖
|
|
14
|
+
publish.py ← 一键发布脚本(不要修改)
|
|
15
|
+
.publish.json ← 发布配置(自动生成,不要手动编辑)
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## 开发规范
|
|
19
|
+
|
|
20
|
+
### 入口文件 server.py
|
|
21
|
+
- 必须叫 `server.py`,不能改名
|
|
22
|
+
- 必须使用 `from mcp.server.fastmcp import FastMCP`
|
|
23
|
+
- 必须以 `mcp.run(transport="stdio")` 结尾
|
|
24
|
+
- 只做 import + 注册工具 + 启动,不写业务逻辑
|
|
25
|
+
- server.py 发布时**不会被加密**(Python 需要它来启动)
|
|
26
|
+
|
|
27
|
+
### 工具函数
|
|
28
|
+
- 每个 `@mcp.tool()` 的 **docstring 是 AI 看到的唯一说明**,必须写清楚:
|
|
29
|
+
- 这个工具做什么
|
|
30
|
+
- 每个参数的含义
|
|
31
|
+
- 返回什么
|
|
32
|
+
- 参数用 Python 类型注解(`str`, `int`, `bool`, `list[str]`)
|
|
33
|
+
- 返回值必须是 `str`(MCP 协议要求)
|
|
34
|
+
- 复杂返回值用 `json.dumps()` 序列化
|
|
35
|
+
|
|
36
|
+
### 核心逻辑
|
|
37
|
+
- 放在独立的 .py 文件中(如 `core.py`、`utils.py`)
|
|
38
|
+
- 在 server.py 中 `from core import xxx` 引用
|
|
39
|
+
- 这些文件发布时**自动加密编译**为 .so/.pyd,用户看不到源码
|
|
40
|
+
|
|
41
|
+
### 用户配置
|
|
42
|
+
- 通过环境变量传递:`os.environ.get("MY_CONFIG", "默认值")`
|
|
43
|
+
- publish.py 会自动扫描 `os.environ.get()` 调用,生成配置界面
|
|
44
|
+
- 用户在 LoongClaw 安装时会看到配置表单
|
|
45
|
+
|
|
46
|
+
## 发布
|
|
47
|
+
|
|
48
|
+
### 交互模式(你来操作)
|
|
49
|
+
```bash
|
|
50
|
+
python publish.py
|
|
51
|
+
```
|
|
52
|
+
按提示回答问题即可。
|
|
53
|
+
|
|
54
|
+
### 自动模式(AI 操作)
|
|
55
|
+
```bash
|
|
56
|
+
# 首次发布
|
|
57
|
+
python publish.py --auto --id my-plugin --version 1.0.0 --access public --upload --json-output
|
|
58
|
+
|
|
59
|
+
# 后续更新(读取 .publish.json 配置)
|
|
60
|
+
python publish.py --auto --version 1.1.0 --json-output
|
|
61
|
+
|
|
62
|
+
# 纯本地打包,不上传
|
|
63
|
+
python publish.py --auto --no-upload --json-output
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### 命令行参数
|
|
67
|
+
| 参数 | 说明 | 示例 |
|
|
68
|
+
|------|------|------|
|
|
69
|
+
| `--auto` | 自动模式(不交互) | |
|
|
70
|
+
| `--id` | 插件 ID | `--id my-plugin` |
|
|
71
|
+
| `--name` | 显示名称 | `--name "我的插件"` |
|
|
72
|
+
| `--version` | 版本号 | `--version 1.0.0` |
|
|
73
|
+
| `--access` | public 或 private | `--access private` |
|
|
74
|
+
| `--skip-encrypt` | 不加密的文件 | `--skip-encrypt "config.py,utils.py"` |
|
|
75
|
+
| `--upload` | 上传到服务器 | |
|
|
76
|
+
| `--no-upload` | 不上传 | |
|
|
77
|
+
| `--token` | API Token | `--token lc-xxx` |
|
|
78
|
+
| `--json-output` | JSON 输出 | |
|
|
79
|
+
| `--reconfigure` | 重新配置 | |
|
|
80
|
+
|
|
81
|
+
### JSON 输出格式
|
|
82
|
+
成功:
|
|
83
|
+
```json
|
|
84
|
+
{"status": "success", "version": "1.0.0", "files_encrypted": 3, "uploaded": true}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
失败:
|
|
88
|
+
```json
|
|
89
|
+
{"status": "error", "step": "encrypt", "error": "SyntaxError at line 42", "fix": "检查 core.py 第 42 行的语法错误"}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**`fix` 字段包含修复建议**——直接按建议修改代码,然后重新运行 `python publish.py --auto --json-output`。
|
|
93
|
+
|
|
94
|
+
## 常见错误
|
|
95
|
+
|
|
96
|
+
| 错误 | 原因 | 修复 |
|
|
97
|
+
|------|------|------|
|
|
98
|
+
| `Cython not found` | 未安装编译器 | `pip install cython` |
|
|
99
|
+
| `编译失败` | macOS 缺 Xcode 工具 | `xcode-select --install` |
|
|
100
|
+
| `缺少入口文件 server.py` | 文件名不对 | 确保入口叫 `server.py` |
|
|
101
|
+
| `未找到 FastMCP 实例` | server.py 没用 FastMCP | 检查 import 和实例化 |
|
|
102
|
+
| `HTTP 401` | Token 无效 | 重新获取 token 或设置 `LOONGCLAW_TOKEN` 环境变量 |
|
|
103
|
+
|
|
104
|
+
## 本地测试
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
# 直接运行测试
|
|
108
|
+
python server.py
|
|
109
|
+
|
|
110
|
+
# 或用 MCP inspector
|
|
111
|
+
mcp dev server.py
|
|
112
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp[cli]>=1.0.0
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""{{PLUGIN_NAME}} — LoongClaw MCP 插件
|
|
3
|
+
|
|
4
|
+
入口文件,负责注册工具并启动 MCP 服务。
|
|
5
|
+
核心业务逻辑请放在其他 .py 文件中(发布时会自动加密)。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from mcp.server.fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
mcp = FastMCP(
|
|
11
|
+
"{{PLUGIN_ID}}",
|
|
12
|
+
instructions=(
|
|
13
|
+
"{{PLUGIN_DESCRIPTION}}"
|
|
14
|
+
),
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@mcp.tool()
|
|
19
|
+
def hello(name: str) -> str:
|
|
20
|
+
"""向用户问好。这是一个示例工具,请替换为你的实际功能。
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
name: 用户的名字
|
|
24
|
+
"""
|
|
25
|
+
return f"你好,{name}!这是 {{PLUGIN_NAME}} 插件。"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
# 在下方注册更多工具...
|
|
29
|
+
# 核心逻辑建议放在单独的 .py 文件中(如 core.py),然后 import 使用:
|
|
30
|
+
#
|
|
31
|
+
# from core import process_data
|
|
32
|
+
#
|
|
33
|
+
# @mcp.tool()
|
|
34
|
+
# def my_tool(input_text: str) -> str:
|
|
35
|
+
# """工具描述(AI 只看这段 docstring,写清楚功能和参数)"""
|
|
36
|
+
# return process_data(input_text)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
if __name__ == "__main__":
|
|
40
|
+
mcp.run(transport="stdio")
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: loongclaw-devkit
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: LoongClaw MCP 开发者工具包 — 一键创建、加密、打包、发布 MCP 插件
|
|
5
|
+
Project-URL: Homepage, https://git.zhigujiaoyu.com.cn/loongclaw/loongclaw-devkit
|
|
6
|
+
Project-URL: Repository, https://git.zhigujiaoyu.com.cn/loongclaw/loongclaw-devkit
|
|
7
|
+
Author: LoongClaw Team
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
Keywords: ai,devkit,loongclaw,mcp,plugin
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Requires-Dist: mcp[cli]>=1.0.0
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
|
|
18
|
+
# LoongClaw DevKit
|
|
19
|
+
|
|
20
|
+
MCP 插件开发者工具包。一键创建、加密打包、发布 MCP 插件到 LoongClaw 商店。
|
|
21
|
+
|
|
22
|
+
## 安装
|
|
23
|
+
|
|
24
|
+
推荐使用 [uvx](https://docs.astral.sh/uv/)(零安装直接运行):
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
uvx loongclaw-devkit
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
或用 pip 安装:
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
pip install loongclaw-devkit
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
## 在 AI 客户端中使用
|
|
37
|
+
|
|
38
|
+
### LoongClaw 桌面客户端
|
|
39
|
+
|
|
40
|
+
在 MCP 商店中搜索 `loongclaw-devkit` 一键安装。
|
|
41
|
+
|
|
42
|
+
### Claude Desktop / Cursor / VS Code
|
|
43
|
+
|
|
44
|
+
在 MCP 配置文件中添加:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"loongclaw-devkit": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["loongclaw-devkit"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## 提供的工具
|
|
58
|
+
|
|
59
|
+
| 工具 | 功能 |
|
|
60
|
+
|------|------|
|
|
61
|
+
| `create_mcp_project` | 创建新的 MCP 插件项目(自动生成模板代码) |
|
|
62
|
+
| `publish_mcp` | 一键加密编译 + 打包 + 发布到 LoongClaw 商店 |
|
|
63
|
+
| `check_mcp_status` | 查看插件的本地和已发布状态 |
|
|
64
|
+
|
|
65
|
+
## 快速开始
|
|
66
|
+
|
|
67
|
+
让 AI 帮你完成整个流程:
|
|
68
|
+
|
|
69
|
+
> "帮我创建一个天气查询的 MCP 插件,插件 ID 叫 weather-mcp"
|
|
70
|
+
|
|
71
|
+
AI 会自动调用 `create_mcp_project` 生成项目骨架,你只需编写核心逻辑。
|
|
72
|
+
|
|
73
|
+
完成后:
|
|
74
|
+
|
|
75
|
+
> "把这个插件发布到 LoongClaw 商店"
|
|
76
|
+
|
|
77
|
+
AI 调用 `publish_mcp` 完成加密编译、打包、上传。
|
|
78
|
+
|
|
79
|
+
## 发布到商店
|
|
80
|
+
|
|
81
|
+
上传到 LoongClaw 商店需要 API Token:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
export LOONGCLAW_TOKEN="your-token-here"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Token 获取方式:在 LoongClaw 客户端「设置 → 开发者」中生成。
|
|
88
|
+
|
|
89
|
+
没有 Token 也可以正常使用创建项目和本地打包功能。
|
|
90
|
+
|
|
91
|
+
## 手动使用
|
|
92
|
+
|
|
93
|
+
也可以不通过 AI,直接命令行运行:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
# 启动 MCP server(开发调试用)
|
|
97
|
+
loongclaw-devkit
|
|
98
|
+
|
|
99
|
+
# 或用 Python 模块方式
|
|
100
|
+
python -m loongclaw_devkit
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
loongclaw_devkit/__init__.py,sha256=r_AyCIRhzjgTi_jJ-QKIrqssGoLiJlrNrzuRl5GbhX4,112
|
|
2
|
+
loongclaw_devkit/__main__.py,sha256=f5HJaAAU3rN_zKkxMf0TNpjb2HnuamEvbxr2_44G44M,90
|
|
3
|
+
loongclaw_devkit/publish.py,sha256=lP5sK_pO7LWCbNNJ4m3mZJvp4f_tcoIRVkEO6tOJT4c,23649
|
|
4
|
+
loongclaw_devkit/server.py,sha256=vTyWZjjzix5ffZuL6xXQsNp_BTHt_lNhgMsoW33S3Qk,9836
|
|
5
|
+
loongclaw_devkit/templates/AGENTS_template.md,sha256=2C9ok1Rx1STZCAaRPgHDKu78mGaaZXLmCMGSvM5aWFY,3777
|
|
6
|
+
loongclaw_devkit/templates/requirements_template.txt,sha256=HZOgIbNCU2k_Aak14zrp6cmD-W2gkbqENLiKHbLdPQI,16
|
|
7
|
+
loongclaw_devkit/templates/server_template.py,sha256=nwIB5_2XjhJUJlVBdhGisiaO6Sxq8e7phC-00TP-iJU,989
|
|
8
|
+
loongclaw_devkit-0.1.0.dist-info/METADATA,sha256=s1-0FuFHZ0BCjGB6PIXSWBOEy3XHZFUhT2CPMCkmMIw,2452
|
|
9
|
+
loongclaw_devkit-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
|
10
|
+
loongclaw_devkit-0.1.0.dist-info/entry_points.txt,sha256=nG_rW60rl-BBSBDlDBaFZMTWB-dGIbeeBsre85MILgY,59
|
|
11
|
+
loongclaw_devkit-0.1.0.dist-info/RECORD,,
|