使用指南
写一个插件
从一个目录、一份清单、一个脚本开始,到装进市场。
插件是把 Mosael 之外的能力接进来的那道口子。写一个不需要读框架源码 —— 一个目录、 一份清单、一个脚本,就是全部。
这篇讲怎么写;插件讲怎么用; manifest 字段说明 是完整的字段表。
先决定一件事:脚本还是 MCP#
判据只有一条:对方有没有现成的 MCP 服务。
有就别写脚本。再写一层把 stdin 的 JSON 翻成一次 HTTP 调用、再把结果翻回 stdout,是在重新 实现一个已经存在的东西,而且每加一个端点都要改代码。接 MCP 只要一份清单,零行代码 —— mcp-everything 就是这么一个例子。
没有,或者你要做的是本地计算(读文件、算东西、调一个私有 HTTP 接口),那就写脚本。 下面讲的是这一种。
五分钟:一个能跑的插件#
建一个目录,两个文件:
my-plugin/
mosael.plugin.json
main.py
清单:
{
"id": "dev.yourname.my-plugin",
"name": "我的插件",
"version": "0.1.0",
"runtime": { "kind": "process", "entry": "main.py" },
"tools": {
"expose": "all",
"declare": [
{ "name": "shout", "description": "把一段文字变成全大写。", "read_only": true }
]
}
}
脚本 —— stdin 一个 JSON 进,stdout 一个 JSON 出:
import json, sys
def shout(payload):
return {"text": str(payload.get("text", "")).upper()}
TOOLS = {"shout": shout}
request = json.loads(sys.stdin.read()) # {"tool": "shout", "input": {...}}
try:
output = TOOLS[request["tool"]](request.get("input") or {})
json.dump({"ok": True, "output": output}, sys.stdout, ensure_ascii=False)
except Exception as exc:
json.dump({"ok": False, "error": str(exc)}, sys.stdout, ensure_ascii=False)
把这个目录放进插件目录(应用的插件页会告诉你它在哪 —— 别照着 ~/.mosael/plugins
去找,Windows 上那个路径不存在),点「扫描」。它就出现在列表里了。
id 用反写域名式的写法。它是这台机器上的唯一键:装进来的目录按它命名,重名会被当成
同一个插件。
规矩#
- 进程 60 秒超时,stdout 上限 1MB,
output必须是个对象。 - 崩了、超时了、吐了非 JSON —— 失败的是那一次调用记录,不是应用。
- 每次调用都留痕(输入、输出、耗时),在插件页看得到。
要参数、要凭据#
在清单里声明,用户在插件页填,运行时以环境变量注入。声明成 credentials 的加密存,
声明成 config 的明文存:
"instance": {
"credentials": [
{ "key": "MY_API_KEY", "label": "API Key", "required": true, "help": "在 xxx 后台生成。" }
],
"config": [
{ "key": "MY_REGION", "label": "区域", "type": "enum", "required": false,
"options": [{ "value": "cn", "label": "国内" }, { "value": "us", "label": "美国" }] }
]
}
key = os.environ["MY_API_KEY"]
拿不到别的东西。 子进程的环境里只有 PATH / HOME / LANG 加上你自己声明的那几个键 ——
拿不到应用的供应商密钥、拿不到数据库、拿不到 API 令牌。这不是限制你,是让用户敢装:
他在插件页看到的那张清单,就是你能碰到的全部。
要交出一个文件#
上面那条路只搬 JSON,1MB 封顶 —— 一个 2GB 的 mp4 塞不进去。要把文件交给素材库,在
output 里放 artifact,有两种交法:
# 一、你自己下好了。必须写在给你的目录里
out = os.environ["MOSAEL_PLUGIN_OUTPUT_DIR"]
path = os.path.join(out, "video.mp4")
download_to(path)
return {"artifact": {"path": "video.mp4"}}
# 二、你只换到了下载凭据,让宿主去下
return {"artifact": {
"url": "https://.../dlink?sign=...",
"headers": {"User-Agent": "..."}, # 有些接口不带特定头就 403
"filename": "video.mp4",
}}
第二种通常更好。 让插件负责换取凭据、宿主负责搬字节 —— 进度、取消、重试、失败隔离 全是现成的,你一行都不用写。而且你这一侧只有一次 60 秒的调用:自己下一个大文件必然超时, 就算不超时,用户也看不到进度、按取消也停不下来。
宿主收下之后,artifact 会被换成 asset_id,调用方拿到的就是一个素材 id,和别的
产素材的工具一样。百度网盘插件走的就是第二种。
要收一个文件#
反过来:你的工具要处理一份已有的素材 —— 传到网盘、发给外部服务转码。在
input_schema 里给那个字段标上 "format": "asset":
{ "name": "upload",
"input_schema": {
"type": "object",
"properties": {
"asset_id": { "type": "string", "format": "asset" },
"path": { "type": "string" }
},
"required": ["asset_id", "path"]
} }
调用方传素材 id,你收到的是一个本地绝对路径:
local = payload["asset_id"] # 已经是路径,不是 id
upload_to_somewhere(local)
你不知道素材库存在,也不需要知道。
给的是副本,不是库里那一份 —— 你改坏了或删掉了都伤不到用户的素材。调用结束即删,
所以别往那个路径写你想留下的东西(想留下就用 artifact 交回去)。
为什么不让你自己去取:你的环境里没有数据库、没有 API 令牌、没有媒体目录 —— 那是隔离边界 的一部分,不是疏漏。
范例见百度网盘插件的 pan_upload。
要记住一点东西#
插件进程是无状态的:环境变量进、JSON 出,跑完就没了,你自己没有任何写回的手段。对纯计算没问题,对要续期的凭据是个死结 —— 拿 refresh_token 换一个新的 access_token 很容易,难的是换完之后没地方放。
在响应里放 state(和 output 平级):
json.dump({
"ok": True,
"output": {"files": [...]}, # 回给调用方
"state": {"MY_ACCESS_TOKEN": "新换的"}, # 宿主替你记住
}, sys.stdout, ensure_ascii=False)
下一次调用它就在环境变量里了 —— 还是 os.environ["MY_ACCESS_TOKEN"],你不需要知道
这个值是上次自己存的。存在哪儿由清单决定:声明成 credentials 的进加密库,声明成 config
的进明文配置。
和 output 平级不是随手放的:output 会交给调用方和模型,而刚续出来的令牌不该出现
在那里。
只能写清单里声明过的键。写了没声明的键直接失败,不是忽略 —— 忽略的话你以为存下了, 下次拿到旧值,而错误表现在几十分钟后的另一个地方。
让它进工作流#
工具默认就能在智能体里用。要让它同时成为一个工作流节点,在那条工具上加 node:
{ "name": "pull", "description": "拉一个文件",
"node": { "label": "从网盘导入", "outputs": ["asset_id", "asset_name"] } }
outputs 里的键,下游节点用 {{节点id.asset_id}} 就能接上。
写说明#
在插件目录里放一个 README.md。它会原样渲染在插件详情页上(比如
百度网盘),所以按给人看的写,别当成内部备忘。
两个坑:相对链接按仓库目录写就好(渲染时会改指回仓库),但别指向不存在的文件;
<https://…> 这种写法在 GitHub 上没问题,写成 [链接](https://…) 更保险。
发出去#
插件市场的索引是一份普通 JSON,谁都能架 —— 包括公司内网:
{"plugins": [{
"id": "dev.yourname.my-plugin", "name": "我的插件", "version": "0.1.0",
"description": "……", "author": "你", "homepage": "https://…",
"download": "https://…/my-plugin.zip",
"permissions": []
}]}
download 指向一个 zip,里面是你那个目录(外面套一层也认 —— 从 GitHub 下下来的包总是
套一层的)。用户在应用里换成你这个索引地址,就能装了。
permissions 那一栏别写错。 用户是照着它决定装不装的,而装插件是在他机器上放一份
会被执行的代码。