@lark-apaas/coding-steering 0.1.57 → 0.1.59
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/package.json +1 -1
- package/steering/nestjs-react-fullstack/skills/client-builtins-file-storage-service/SKILL.md +16 -12
- package/steering/nestjs-react-fullstack/skills/code-fix/SKILL.md +2 -2
- package/steering/nestjs-react-fullstack/skills/coding-guide/SKILL.md +20 -5
- package/steering/nestjs-react-fullstack/skills/contacts-service/SKILL.md +46 -32
- package/steering/nestjs-react-fullstack/skills/design-guide/SKILL.md +0 -3
- package/steering/nestjs-react-fullstack/skills/design-guide/references/token-mapping.md +3 -3
- package/steering/nestjs-react-fullstack/skills/openapi-guide/SKILL.md +28 -59
- package/steering/nestjs-react-fullstack/skills/plugin-guide/SKILL.md +17 -2
- package/steering/nestjs-react-fullstack/skills/plugin-guide/references/plugin-coding-guide.md +127 -2
- package/steering/nestjs-react-fullstack/skills/server-builtins-file-storage-service/SKILL.md +210 -177
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/SKILL.md +78 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/eslint.mcp-ui.config.cjs +19 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/assets/ui-tsconfig.json +19 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md +82 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/debugging.md +81 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/mcp-apps.md +197 -0
- package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/tool-authoring.md +249 -0
- package/steering/nestjs-react-fullstack/skills_common/server-contacts-contract/SKILL.md +19 -0
- package/steering/nestjs-react-fullstack/skills_common/user-identity/SKILL.md +5 -1
- package/steering/nestjs-react-fullstack/skills_local/code-fix/SKILL.md +2 -2
- package/steering/nestjs-react-fullstack/skills_local/coding-guide/SKILL.md +18 -3
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/SKILL.md +3 -0
- package/steering/nestjs-react-fullstack/skills_local/plugin-guide/references/plugin-coding-guide.md +93 -2
- package/steering/vite-react/skills/plugin-guide/SKILL.md +2 -0
- package/steering/nestjs-react-fullstack/skills/design-guide/references/corporate-blueprint.md +0 -252
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
|
5
|
+
"module": "ESNext",
|
|
6
|
+
"moduleResolution": "Bundler",
|
|
7
|
+
"jsx": "react-jsx",
|
|
8
|
+
"strict": true,
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
"checkJs": true,
|
|
11
|
+
"types": ["react", "react-dom"],
|
|
12
|
+
"skipLibCheck": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
"esModuleInterop": true,
|
|
15
|
+
"baseUrl": "../../..",
|
|
16
|
+
"paths": { "@shared/*": ["shared/*"] }
|
|
17
|
+
},
|
|
18
|
+
"include": ["./**/*.ts", "./**/*.tsx", "./**/*.js", "./**/*.jsx", "./**/*.mts", "./**/*.cts", "./**/*.mjs", "./**/*.cjs"]
|
|
19
|
+
}
|
package/steering/nestjs-react-fullstack/skills_common/mcp-guide/references/client-onboarding.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# 接入:把本应用的 MCP 连到外部客户端
|
|
2
|
+
|
|
3
|
+
适用于「应用已经有 MCP 工具,用户要在自己的 Agent(Claude Code / Codex / 其它 MCP 客户端)里调用它」。开发态自己验工具走 `debugging.md` 的 `mcp_inspect`,与本文无关。
|
|
4
|
+
|
|
5
|
+
**产出是一条用户复制即可执行的命令,不是操作说明。**连接地址和凭证都由你查出来填好,不要让用户自己去界面上找。
|
|
6
|
+
|
|
7
|
+
## 取连接配置
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
lark-cli apps +mcp-get --app-id "$app_id" --include-secret
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`data.config` 里是完整的 `mcpServers` 对象,含连接地址与 `X-Mcp-Token`。不加 `--include-secret` 拿到的是脱敏展示(`data.redacted=true`),不能用于连接。
|
|
14
|
+
|
|
15
|
+
命令语义、风险等级与权限要求以 `lark-apps-ops` 的 `references/lark-apps-mcp.md` 与各命令 `--help` 为准,本文不复述。两条只在这里强调:
|
|
16
|
+
|
|
17
|
+
- **该命令会创建缺失的运行态凭证,风险是 `write`。**不要仅为「看看这应用开没开 MCP」而调用它。重复调用返回已有凭证,不轮换、不重置。
|
|
18
|
+
- 拿不到命令(沙箱 CLI 版本不带该子命令)时如实告知用户,**不要自己拼连接地址兜底**——地址形如 `https://<租户域名>.<环境后缀>/mcp/app`,两段都不可推测。
|
|
19
|
+
|
|
20
|
+
## 服务名你来起
|
|
21
|
+
|
|
22
|
+
`<名字>` 会成为客户端里的工具前缀(`mcp__<名字>__<工具名>`),用户此后每次看到这些工具都要读它。**直接给一个贴合本 MCP 用途的名字,不要问用户要。**
|
|
23
|
+
|
|
24
|
+
- 取材于这个 MCP 做什么:差旅审批叫 `travel-approval`,库存查询叫 `inventory`
|
|
25
|
+
- 小写字母 + 数字 + `-` 或 `_`,两三个词以内;不要空格、点号、中文
|
|
26
|
+
- 同一台机器内唯一。用户可能已经接过别的妙搭应用,泛指类的名字会撞上
|
|
27
|
+
- server 声明的 `serverName` 一眼能看出这个 MCP 做什么时,直接用它,保持开发态与客户端一致
|
|
28
|
+
- 给完顺带说一句名字可改,但不要停下来等用户确认
|
|
29
|
+
|
|
30
|
+
## 落到客户端
|
|
31
|
+
|
|
32
|
+
从 `data.config` 的 `mcpServers` 条目里取出连接地址与请求头,连同上面定好的名字填进下面对应模板,整条给用户。
|
|
33
|
+
|
|
34
|
+
**Claude Code**
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
claude mcp add --transport http <名字> <连接地址> --header "X-Mcp-Token: <凭证>"
|
|
38
|
+
claude mcp login <名字>
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Codex**
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
codex mcp add <名字> --url "<连接地址>?x-mcp-token=<凭证>"
|
|
45
|
+
codex mcp login <名字>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
`codex mcp add` 没有传请求头的参数,凭证走 query 参数。不要用 `--bearer-token-env-var`,它会走静态 token 分支把 OAuth 跳过去。
|
|
49
|
+
|
|
50
|
+
## 为什么是两条命令
|
|
51
|
+
|
|
52
|
+
`X-Mcp-Token` 认「连的是哪个应用」,第二条命令办的是「谁在调用」——浏览器里完成一次飞书授权,客户端拿到用户令牌并自行续期。两者缺一都连不上,也互相替代不了。应用凭证不是用户身份,查出凭证不等于用户已获授权。
|
|
53
|
+
|
|
54
|
+
`login` 只服务 HTTP 传输。用户若报「only supported for streamable HTTP servers」,是配置成了本地 stdio 形态,按上面的模板改成 `url` 形式。
|
|
55
|
+
|
|
56
|
+
## 两件命令解决不了的事
|
|
57
|
+
|
|
58
|
+
凭证能提前建,但要真正调通还需要:
|
|
59
|
+
|
|
60
|
+
| 条件 | 不满足时的表现 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| 应用已发布且线上 MCP 已启用 | 连接失败;创建凭证不要求发布,所以拿到凭证不代表能连 |
|
|
63
|
+
| 调用人在应用可用范围内 | 403,与凭证是否有效无关 |
|
|
64
|
+
|
|
65
|
+
这两条你查不到也改不了,连不上时按下表对号,属于这两类就直接告诉用户去处理,不要反复重试。
|
|
66
|
+
|
|
67
|
+
## 排错
|
|
68
|
+
|
|
69
|
+
| 现象 | 原因 | 动作 |
|
|
70
|
+
|---|---|---|
|
|
71
|
+
| 401,reason `lark_oauth_client_mismatch` | 授权用的 client_id 与服务端元数据不一致 | 清掉客户端授权缓存重新 `login` |
|
|
72
|
+
| 401,reason `lark_oauth_conf_unavailable` | 平台侧配置问题 | 告知用户找平台同学,不要让他反复重试 |
|
|
73
|
+
| 401,无 reason | 凭证复制不全,或配的是预览态凭证 | 重新 `+mcp-get --include-secret` 取一次,整段替换 |
|
|
74
|
+
| 403 `user is not in the visible scope of this app` | 不在可用范围,或授权账号不属于应用租户 | 前者找负责人放开范围;后者换应用租户内的账号授权 |
|
|
75
|
+
| 404 | 应用没启用 MCP;或零工具零资源无应用 Skill(SDK 此时也返回 404) | 后者是正常状态不是故障,先确认本应用确实注册了工具 |
|
|
76
|
+
| POST 被拒,CSRF 相关 | 旧模板应用对 POST 有 CSRF 双提交校验 | 平台侧已知未收口项,告知用户,不在客户端侧拼 cookie 绕 |
|
|
77
|
+
|
|
78
|
+
## 凭证纪律
|
|
79
|
+
|
|
80
|
+
- `--include-secret` 的输出只用于拼那条给用户的命令。**不写进项目任何文件、不进日志、不复述进汇报与摘要、不提交仓库。**
|
|
81
|
+
- 保持客户端默认的 local scope;不要建议 `--scope project`——那会把凭证写进随代码提交的配置文件。
|
|
82
|
+
- 用户自己贴出过凭证(聊天、issue、文档)时提醒他重置,旧值立即失效。
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# 调试:mcp_inspect、错误定位、本地模式
|
|
2
|
+
|
|
3
|
+
本文说的「沙箱」= 工具清单里有 `mcp_inspect` 的环境(妙搭云端 code-agent);「本地模式」= 没有 `mcp_inspect`、在开发者本机跑 `npm run dev` 的环境。先看自己有哪个,再读对应部分。
|
|
4
|
+
|
|
5
|
+
## mcp_inspect
|
|
6
|
+
|
|
7
|
+
云端沙箱里的开发态 MCP 调试工具。它经 MCP 网关连当前应用的开发态 MCP Server,以当前用户身份发起,与预览页里操作应用的身份一致。
|
|
8
|
+
|
|
9
|
+
| action | 参数 | 返回 |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `list_tools` | 无 | server 名、每个工具的 name / title / description / annotations / `_meta.ui` / inputSchema(长 schema 截断)、资源清单 |
|
|
12
|
+
| `call` | `tool_name`(带 prefix 的完整名)、`arguments`(JSON 对象**字符串**,缺省 `{}`)、`safe` | 文本内容、`structuredContent`、`isError`、耗时;正文整体超 12k 字符时从尾部截断,`structuredContent` 的末尾最先被吃掉。脱敏后的完整结果超过 12k 字符时落盘到 `tmp/<会话>/tool/<调用ID>/result.json`,结果尾部给出路径,需要全量数据时 `read_file` 读它,不要缩小入参反复重调 |
|
|
13
|
+
| `read_resource` | `resource_uri`(如 `ui://order/list`) | uri、mimeType、`_meta.ui`、正文前若干字符 |
|
|
14
|
+
|
|
15
|
+
- `safe`:工具未声明 `annotations.readOnlyHint: true` 时,`call` 在发起前被拒为 `requires_safe`。只有这次调用影响的是你自建、且事后能自己清掉的数据时才传 `safe: true`;用户已有的真实数据未经明确要求不动。只读工具反复撞这条,是源码漏了 `readOnlyHint: true`,补注解而不是加 `safe`。
|
|
16
|
+
- 单个子请求 60s 无动静即超时,整次动作 600s 硬上限。server 是无状态 JSON 模式(不开 SSE),`ctx.extra.sendNotification` 发的 `notifications/progress` 到不了客户端、不会重置计时;超时只能靠缩小单次处理范围。
|
|
17
|
+
- 同一工具连续失败 4 次熔断 60s:先改代码或入参,不是等。`requires_safe`、`tool_not_found`、`aborted` 不计入熔断。
|
|
18
|
+
- 失败或 `isError: true` 时结果尾部附 Trace-ID 对应的 `server.log` / `trace.log` 片段。
|
|
19
|
+
|
|
20
|
+
**目录接口**:`GET http://localhost:<CLIENT_DEV_PORT><CLIENT_BASE_PATH>/__innerapi__/mcp/manifest`(`CLIENT_DEV_PORT` 默认 8080;`CLIENT_BASE_PATH` 沙箱里是平台下发的 `/app/<appId>`,漏了前缀拿不到 JSON,取值以 `.env.local` 或 dev server 启动日志为准)返回 `tools` / `resources` / `prompts` / `skills[]`(含 frontmatter 的完整正文)与源码定位,不要求身份,有无 `mcp_inspect` 都能 curl。应用 Skill 是 Prompt 不是资源,`mcp_inspect` 的三个动作都碰不到它,核对它只走这里。
|
|
21
|
+
|
|
22
|
+
## 回路
|
|
23
|
+
|
|
24
|
+
1. 改工具 / 资源 / 界面。
|
|
25
|
+
2. 等 dev server 重启(`nest --watch` 会自动重启;界面改动由 `[mcp-ui]` 插件自动重建)。定义有问题会让应用起不来并打印「MCP 定义校验失败」,修完再往下走。
|
|
26
|
+
3. `list_tools` 核对清单与源码一致(`visibility: ['app']` 的工具也在清单里,属正常)。
|
|
27
|
+
4. `call` 逐个只读工具跑一次真实入参。写工具只在能自己清掉痕迹(有对应删除 / 撤销工具,或业务上可撤回)时才真调并随手清理;否则只验证入参校验与 `McpToolError` 分支,汇报里写明「写入链路未真实验证」。
|
|
28
|
+
5. 带界面的工具 `read_resource`。
|
|
29
|
+
6. 每个 `McpToolError` 分支构造一次触发(不存在的 ID、非法状态),确认 `isError: true` 且 `code` 与 `description` 预告一致。
|
|
30
|
+
7. 更新 `server/mcp/skills/<name>/SKILL.md`,再 curl 目录接口(frontmatter 不合法这里会 500):`skills[]` 有该 `name`、`content` 是最新正文,`prompts[]` 的 `description` 一致。
|
|
31
|
+
|
|
32
|
+
## 错误分流
|
|
33
|
+
|
|
34
|
+
| 现象 | 原因 | 动作 |
|
|
35
|
+
|---|---|---|
|
|
36
|
+
| 端点 404「当前工程可能尚未接入 MCP 层,或 dev server 尚未就绪」 | `server/mcp` 不存在;dev server 没起来;零工具零资源无应用 Skill(SDK 此时返回 404) | 确认三者;有工具但 404 看启动日志是否被校验阻断 |
|
|
37
|
+
| `list_tools` 少了某个工具 | 类没进任何模块 `providers`,无任何日志提示 | 对照启动日志「已注册 N 个 MCP 工具:…」,缺的加进所属业务模块的 `providers` |
|
|
38
|
+
| dev server 起不来,报「MCP 工具必须使用单例及单例依赖」 | 工具类或其依赖是 request / transient 作用域 | 改回单例,身份从 `ctx.user` 取 |
|
|
39
|
+
| 启动日志「MCP 定义校验失败」 | 工具名不合法 / 重名、资源 URI 不以 `ui://` 开头 / 重名、`ui.resourceUri` 找不到 `@McpUiResource()`、同一方法同时标两个装饰器 | 按日志逐条列的问题逐个修 |
|
|
40
|
+
| `tool_not_found` | 名字没带 prefix,或用了 `title` | 用 `list_tools` 里的 name |
|
|
41
|
+
| `requires_safe` | 见上文 `safe` | 只读工具补注解;写工具按回路第 5 步——能清掉痕迹才 `safe: true`,否则只验错误分支 |
|
|
42
|
+
| `isError` + `code: MCP_USER_REQUIRED`(`prompts/get` / `resources/read` 则是 JSON-RPC error) | 请求没带用户身份:有 `mcp_inspect` 的环境里 curl 直连 dev server / NestJS 端口就是这样 | 无身份的 curl 只做 `tools/list`、`prompts/list`、`resources/list` 与目录接口;`tools/call` / `resources/read` 走 `mcp_inspect`,Prompt 正文看目录接口的 `skills[].content`。不改 `requireUser`、不拼身份头 |
|
|
43
|
+
| `isError` + 业务 `code` | 工具按设计抛了 `McpToolError` | 对照 `description` 检查是否预告了该 code;入参是否真的该触发 |
|
|
44
|
+
| `isError` + 通用失败提示 | 方法抛了非 `McpToolError` 的异常 | 读结果尾部 Trace-ID 日志定位;该异常若是可预期的业务失败,改抛 `McpToolError` |
|
|
45
|
+
| zod 校验错误 | 入参不符 inputSchema | 对照 `list_tools` 里的 schema 改 `arguments`;schema 本身不合理就改源码 |
|
|
46
|
+
| 「工具已执行,但输出与其声明的 outputSchema 不符」 | `structuredContent` 与 `outputSchema` 对不上 | 工具已跑通:改返回值或改 schema,不重试同一调用 |
|
|
47
|
+
| `timeout` | 单个子请求 60s 无动静,或整次超 600s | 收窄入参 / 分页 / 拆成「发起 + 查状态」两个工具;加进度通知无效(JSON 模式发不出去) |
|
|
48
|
+
| `connect_failed` / `http_error` / `unauthorized` | 网关或预览环境问题;`http_error` 500 先对下面 frontmatter 那行,应用自己也会 500 | 看 statusCode;不是应用代码问题时告知用户,不改代码绕 |
|
|
49
|
+
| `jsonrpc_error` + `data.code: MCP_APP_REVISION_MISMATCH` | 调用方带的应用修订与当前产物不一致(改代码、改界面、改应用 Skill、dev server 重启都会变),**本次业务未执行** | 重新 `list_tools` 取最新清单再调;不要原样重试,也不要把这当业务失败去改工具代码 |
|
|
50
|
+
| `jsonrpc_error` + `data.code: MCP_APP_REVISION_UNAVAILABLE` | 产物是旧版构建、不带修订,而调用方带了预期修订 | 同步新版构建脚本后重新发布;在那之前不要启用依赖修订的绑定 |
|
|
51
|
+
| 「已连续 4 次调用失败,已暂停对它的重复调用」 | 同一工具连续失败 4 次熔断 | 先修实现或入参,60s 后再验 |
|
|
52
|
+
| 「当前运行环境不支持开发态 MCP 调试」 | 本地 CLI 宿主没有 `mcp_inspect` 后端 | 走下节本地模式 |
|
|
53
|
+
| `read_resource` 报「读取 MCP Apps 界面产物失败」 | `dist/mcp-ui/<entry>.html` 不存在 | 入口名对齐 `readMcpUiTemplate('<entry>')`;确认 `server/mcp/ui/<entry>/index.html` 存在;看 `[mcp-ui]` 日志是否构建失败 |
|
|
54
|
+
| dev server 日志 `[mcp-ui] 构建失败:MCP UI 不能引用应用或服务端代码 / Node.js 模块` | 界面 import 了 `client/`、`server/` 的业务代码或 Node 内置模块;HTML `src`、CSS `url()` 指到界面目录外也算 | 把依赖挪进 `server/mcp/ui/` 或 `shared/`;不改预设配置 |
|
|
55
|
+
| `mcp_inspect` 任一动作 `http_error` 500、目录接口 500,server 日志有「处理 MCP 请求失败」、栈首行是「MCP Skill …」 | 某份 `server/mcp/skills/*/SKILL.md` frontmatter 不合法:缺 frontmatter、`name` 与目录名不等、`description` 空、重名、超限 | 按日志文案修 frontmatter |
|
|
56
|
+
|
|
57
|
+
同一失败不原样重试。改完代码再验,每轮只改一个变量。
|
|
58
|
+
|
|
59
|
+
## 本地模式(工具清单里没有 `mcp_inspect` 时才适用)
|
|
60
|
+
|
|
61
|
+
本地开发(`MIAODA_LOCAL_DEV=1`,`npm run dev` 走 `dev-local.js`)没有 `mcp_inspect`,MCP 客户端是你自己或 IDE 里的 Agent。
|
|
62
|
+
|
|
63
|
+
- 带身份的入口只有 dev server:`http://localhost:<CLIENT_DEV_PORT><CLIENT_BASE_PATH>/__innerapi__/mcp`(`CLIENT_BASE_PATH` 以 `.env.local` 为准,`+env-pull` 拉下来的是 `/app/<appId>`)。dev server 代理把 `+env-pull` 拉到的 `SUDA_WEBUSER` 注入为身份头,工具里的 `ctx.user` 就是开发者本人。直连 NestJS 端口没有身份,会撞 `MCP_USER_REQUIRED`。
|
|
64
|
+
- 标准 MCP 客户端配置 Streamable HTTP、URL 填上面地址即可连接。
|
|
65
|
+
- 看原始请求响应:`npx @modelcontextprotocol/inspector`,指向同一地址。
|
|
66
|
+
- 快速验证:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
curl -s -X POST 'http://localhost:<port><base>/__innerapi__/mcp' \
|
|
70
|
+
-H 'content-type: application/json' \
|
|
71
|
+
-H 'accept: application/json, text/event-stream' \
|
|
72
|
+
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`accept` 必须同时含两种类型,否则 406。`GET` / `DELETE` 返回 405,端点只接受 `POST`;目录接口见上文。
|
|
76
|
+
|
|
77
|
+
## 汇报
|
|
78
|
+
|
|
79
|
+
- 用业务表述:「`order_search` 返回 3 条待支付订单」「取消不存在的订单返回 ORDER_NOT_FOUND,符合预期」;不贴整段 JSON。
|
|
80
|
+
- 写工具真调过的,说明动了哪些数据、是否已清理;没真调的写明「写入链路未真实验证」。
|
|
81
|
+
- 未验证的分支明说未验证,不写「已全部通过」。
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# MCP Apps:给工具结果加界面
|
|
2
|
+
|
|
3
|
+
一个 MCP App = 一个 `ui://` 资源(单文件 HTML)+ 至少一个用 `ui.resourceUri` 指向它的工具。宿主调用工具后把 HTML 放进沙箱 iframe 渲染,再把工具入参与结果通过通知推给界面;界面里的操作经宿主回调 server 的工具。
|
|
4
|
+
|
|
5
|
+
## 选哪种界面
|
|
6
|
+
|
|
7
|
+
| 信号 | 界面类型 |
|
|
8
|
+
|---|---|
|
|
9
|
+
| 工具已返回候选,要用户挑一个或几个再继续(航班、订单、文件) | 选择型,选中后 `updateModelContext` 同步给模型 |
|
|
10
|
+
| 结果是图表、地图、对比、文件预览等看图才懂的信息 | 展示型 |
|
|
11
|
+
| 写操作前要让用户在界面里核对、确认 | 确认型,确认动作经 `callServerTool` 调一个 `visibility: ['app']` 的工具 |
|
|
12
|
+
| 工具跑之前要用户补一组结构化输入(多字段表单、长列表里选参数) | 表单型。服务端中途问不了用户(没有 elicitation),只有模型和界面能问 |
|
|
13
|
+
| 长任务要用户看着进展 | 进度型:发起工具绑界面并返回任务 ID,界面轮询 `visibility: ['app']` 的查状态工具。进度通知在无状态 JSON 模式下发不出去,界面是唯一的路 |
|
|
14
|
+
| 以上都不匹配 | 不做界面 |
|
|
15
|
+
|
|
16
|
+
## 目录与构建
|
|
17
|
+
|
|
18
|
+
```text
|
|
19
|
+
server/mcp/ui/
|
|
20
|
+
├── tsconfig.json # 界面独立的 TS 配置,整个 ui/ 共用一份,从本 skill 的 assets/ 拷
|
|
21
|
+
└── <entry>/
|
|
22
|
+
├── index.html # 入口骨架,见下
|
|
23
|
+
└── main.tsx # 或 main.ts;React 或原生 JS 都行
|
|
24
|
+
dist/mcp-ui/<entry>.html # 预设产物,@McpUiResource 经 readMcpUiTemplate 读取
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
```html
|
|
28
|
+
<!-- server/mcp/ui/<entry>/index.html -->
|
|
29
|
+
<!doctype html>
|
|
30
|
+
<html><head><meta charset="utf-8" /></head>
|
|
31
|
+
<body><div id="root"></div><script type="module" src="./main.tsx"></script></body></html>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
`<div id="root">` 不能少:缺了 `createRoot(document.getElementById('root')!)` 拿到 null,iframe 全白,而 `read_resource` 照样能读到 HTML,不会报错。
|
|
35
|
+
|
|
36
|
+
- `<entry>` 只允许 `A-Z a-z 0-9 _ -`。
|
|
37
|
+
- `@lark-apaas/coding-preset-vite-react` 的 `mcp-ui` 插件:检测到 `server/mcp/ui/` 就为每个入口跑一次独立子构建,JS / CSS / 资源全部内联成一个 HTML。开发态监听 `server/mcp/ui/` 与 `shared/`,改完自动重建;生产在主构建之后构建,并同步进发布目录。
|
|
38
|
+
- 子构建只带 React 插件与 `@shared` 别名,不加载主应用的 `vite.config.ts`、`.env`、`public/` 与 PostCSS 配置:`@/`、`@client/*`、`@server/*` 解析不到,不支持 styled-jsx、Tailwind 与主应用全局样式。样式写在入口自己的 CSS 里;颜色字体优先读宿主下发的 CSS 变量(`useHostStyles`),深浅两套主题下都要能看。
|
|
39
|
+
- 依赖边界由子构建强制:界面只能 import `server/mcp/ui/` 内的文件、`shared/` 与浏览器可用的 npm 包。引到 `client/`、`server/` 里的业务代码、工具类或服务端实现报「MCP UI 不能引用应用或服务端代码」,引到 `fs`、`path` 等报「MCP UI 不能引用 Node.js 模块」;HTML 的 `src` / `href`、CSS 的 `url()` 指到界面目录外同样被拒。要复用的纯展示组件挪到 `shared/`,且不得依赖应用的 Provider、Router 或登录态。
|
|
40
|
+
- 界面不继承主应用的 TS / ESLint 配置,各自带一份,首次开通界面从本 skill 的 `assets/` 拷进工程。逐字照拷、别自己写:子构建只转译不查类型,配置漏一项,错误要等界面在 iframe 里白屏才暴露。`SKILL_DIR` 取技能清单里本 skill 的 location 所在目录:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
SKILL_DIR=<本 skill SKILL.md 所在目录>
|
|
44
|
+
mkdir -p server/mcp/ui
|
|
45
|
+
cp "$SKILL_DIR/assets/ui-tsconfig.json" server/mcp/ui/tsconfig.json
|
|
46
|
+
cp "$SKILL_DIR/assets/eslint.mcp-ui.config.cjs" eslint.mcp-ui.config.cjs
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
- `@modelcontextprotocol/ext-apps` 随 SDK 安装,直接 import。解析失败时把它加进 `package.json` `dependencies`,版本与 `node_modules/@lark-apaas/nestjs-mcp/package.json` 里声明的一致(1.x),不装 2.x。
|
|
50
|
+
|
|
51
|
+
## server 侧:声明资源并关联工具
|
|
52
|
+
|
|
53
|
+
```typescript
|
|
54
|
+
import { McpTools, McpTool, McpUiResource, readMcpUiTemplate } from '@lark-apaas/fullstack-nestjs-core';
|
|
55
|
+
|
|
56
|
+
@McpTools({ prefix: 'order_' })
|
|
57
|
+
export class OrderMcpTools {
|
|
58
|
+
@McpTool({
|
|
59
|
+
title: '查询订单',
|
|
60
|
+
description: '…',
|
|
61
|
+
inputSchema: SearchOrdersInput,
|
|
62
|
+
outputSchema: SearchOrdersOutput,
|
|
63
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
64
|
+
ui: { resourceUri: 'ui://order/list' }, // 结果由该界面渲染;模型与界面都可调
|
|
65
|
+
})
|
|
66
|
+
async search(/* … */) { /* … */ }
|
|
67
|
+
|
|
68
|
+
@McpTool({
|
|
69
|
+
description: '按 ID 取一条订单明细,供订单列表界面展开详情时调用。',
|
|
70
|
+
inputSchema: GetOrderInput,
|
|
71
|
+
outputSchema: GetOrderOutput,
|
|
72
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
73
|
+
ui: { resourceUri: 'ui://order/list', visibility: ['app'] }, // 只供界面回调,模型工具清单里隐藏
|
|
74
|
+
})
|
|
75
|
+
async detail(/* … */) { /* … */ }
|
|
76
|
+
|
|
77
|
+
@McpUiResource({
|
|
78
|
+
uri: 'ui://order/list',
|
|
79
|
+
title: '订单列表',
|
|
80
|
+
description: '分页订单列表,可展开详情、勾选后取消。',
|
|
81
|
+
csp: { connectDomains: [], resourceDomains: [] }, // 界面要访问的外部域名写这里,默认全禁
|
|
82
|
+
})
|
|
83
|
+
listView() {
|
|
84
|
+
return readMcpUiTemplate('order-list'); // 读 dist/mcp-ui/order-list.html
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
- `uri` 必须以 `ui://` 开头,全应用唯一;`ui.resourceUri` 找不到对应 `@McpUiResource()` 时应用启动会报错。
|
|
90
|
+
- `visibility` 默认 `['model', 'app']`。只给界面用的取数 / 刷新工具写 `['app']`,避免污染模型的工具清单。隐藏由宿主做,server 不过滤:`mcp_inspect` 的 `list_tools` 仍会列出它,`_meta.ui.visibility` 为 `['app']` 即正确,不要因为「还在清单里」去改代码。
|
|
91
|
+
- 绑了界面就在 `description` 里说出来(「结果以可交互的订单列表展示,可在界面里展开详情、取消订单」)。模型只看 `description` 选工具,不写它可能改调别的工具或自己拼 ID。
|
|
92
|
+
- 一个工具既返回 `structuredContent` 又绑界面是常态;模型还需要纯数据继续推理时,拆成「取数工具(无 ui)」+「展示工具(绑 ui)」。
|
|
93
|
+
- 资源读取沿用 `requireUser`:无身份的 `resources/read` 会被拒。
|
|
94
|
+
|
|
95
|
+
## 界面侧:接结果、回调工具
|
|
96
|
+
|
|
97
|
+
React 写法(`@modelcontextprotocol/ext-apps/react`):
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
// server/mcp/ui/order-list/main.tsx
|
|
101
|
+
import { useState } from 'react';
|
|
102
|
+
import { createRoot } from 'react-dom/client';
|
|
103
|
+
import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
|
|
104
|
+
// 与 outputSchema 对齐的 TS 类型放 shared/(如 shared/mcp-types.ts),server 与界面共用;@shared 是子构建唯一可用别名
|
|
105
|
+
import type { OrderPage as Page } from '@shared/mcp-types';
|
|
106
|
+
|
|
107
|
+
function OrderList() {
|
|
108
|
+
const [page, setPage] = useState<Page | null>(null);
|
|
109
|
+
const [error, setError] = useState<string | null>(null);
|
|
110
|
+
|
|
111
|
+
const { app, isConnected } = useApp({
|
|
112
|
+
appInfo: { name: 'order-list', version: '1.0.0' },
|
|
113
|
+
capabilities: {},
|
|
114
|
+
onAppCreated: (app) => {
|
|
115
|
+
// 宿主推送:工具入参(可先渲染骨架)、工具结果、取消
|
|
116
|
+
app.ontoolinput = ({ arguments: args }) => console.debug('input', args);
|
|
117
|
+
app.ontoolresult = (result) => {
|
|
118
|
+
if (result.isError) setError(textOf(result));
|
|
119
|
+
else setPage(result.structuredContent as Page);
|
|
120
|
+
};
|
|
121
|
+
app.ontoolcancelled = () => setError('调用已取消');
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
useHostStyles(app, app?.getHostContext()); // 应用宿主主题、CSS 变量与字体;第二参数让首帧就生效
|
|
125
|
+
|
|
126
|
+
async function cancel(orderId: string) {
|
|
127
|
+
if (!app) return;
|
|
128
|
+
const res = await app.callServerTool({ name: 'order_cancel', arguments: { orderId } });
|
|
129
|
+
if (res.isError) return setError(textOf(res));
|
|
130
|
+
// 把用户在界面里做的选择同步给模型,模型下一轮据此继续
|
|
131
|
+
await app.updateModelContext({
|
|
132
|
+
content: [{ type: 'text', text: `用户已在界面中取消订单 ${orderId}` }],
|
|
133
|
+
structuredContent: { cancelledOrderId: orderId },
|
|
134
|
+
});
|
|
135
|
+
setPage((p) => p && { ...p, items: p.items.filter((i) => i.id !== orderId) });
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (!isConnected) return <p>连接宿主中…</p>;
|
|
139
|
+
if (error) return <p role="alert">{error}</p>;
|
|
140
|
+
if (!page) return <p>等待查询结果…</p>;
|
|
141
|
+
return (
|
|
142
|
+
<ul>
|
|
143
|
+
{page.items.map((o) => (
|
|
144
|
+
<li key={o.id}>
|
|
145
|
+
{o.customer} · {o.amount} 元 · {o.status}
|
|
146
|
+
{o.status === 'pending' && <button onClick={() => cancel(o.id)}>取消</button>}
|
|
147
|
+
</li>
|
|
148
|
+
))}
|
|
149
|
+
</ul>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function textOf(result: { content?: Array<{ type: string; text?: string }> }) {
|
|
154
|
+
return result.content?.find((c) => c.type === 'text')?.text ?? '调用失败';
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
createRoot(document.getElementById('root')!).render(<OrderList />);
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
原生 JS 写法(`@modelcontextprotocol/ext-apps`):
|
|
161
|
+
|
|
162
|
+
```typescript
|
|
163
|
+
import { App } from '@modelcontextprotocol/ext-apps';
|
|
164
|
+
const app = new App({ name: 'order-list', version: '1.0.0' }, {});
|
|
165
|
+
app.ontoolresult = (result) => render(result.structuredContent);
|
|
166
|
+
await app.connect();
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
`ontoolresult` 等回调必须在 `connect()` 之前挂好——`useApp` 用 `onAppCreated` 注册就是为此,原生写法先赋值再 `connect()`。连上之后再赋值会漏掉宿主推来的第一帧结果,界面停在「等待查询结果…」——页面本身不报错,但 ext-apps 会在控制台 warn 一句 handler 注册太晚。
|
|
170
|
+
|
|
171
|
+
宿主能力速查(`App` 实例方法):
|
|
172
|
+
|
|
173
|
+
| 方法 | 用途 |
|
|
174
|
+
|---|---|
|
|
175
|
+
| `ontoolinput` / `ontoolinputpartial` / `ontoolresult` / `ontoolcancelled` | 宿主推送的入参(完整 / 流式)、结果、取消 |
|
|
176
|
+
| `callServerTool({ name, arguments })` | 经宿主调本 server 的工具;工具名带 prefix;返回标准 `CallToolResult` |
|
|
177
|
+
| `updateModelContext({ content?, structuredContent? })` | 把界面状态同步给模型,不触发模型立刻回复 |
|
|
178
|
+
| `sendMessage({ role: 'user', content })` | 以用户身份发一条消息,触发模型回复 |
|
|
179
|
+
| `getHostContext()` / `onhostcontextchanged` | 主题、`displayMode`、`containerDimensions`、`locale`、`timeZone`、发起本次渲染的 `toolInfo` |
|
|
180
|
+
| `requestDisplayMode({ mode })` | 请求 inline / fullscreen 等展示模式,以 `availableDisplayModes` 为限 |
|
|
181
|
+
|
|
182
|
+
`useApp` 默认开自动调高(`autoResize`),内容变化不用手动发尺寸。
|
|
183
|
+
|
|
184
|
+
## 界面约束
|
|
185
|
+
|
|
186
|
+
- 不存凭证、不读 cookie / localStorage 里的登录态:iframe 是独立 origin,用户在业务系统的登录态带不进来。所有数据走 `callServerTool`,身份由宿主转发。
|
|
187
|
+
- 外部域名写进 `@McpUiResource` 的 `csp`:`connectDomains`(fetch / WebSocket)、`resourceDomains`(图片、脚本、样式)、`frameDomains`(内嵌 iframe)、`baseUriDomains`;默认全禁,漏写表现为资源静默加载失败。
|
|
188
|
+
- 业务返回值用 `textContent` 或 JSX 文本渲染,不拼 `innerHTML`。
|
|
189
|
+
- 每次调用重新注入数据;界面不假设自己能跨调用保留状态。
|
|
190
|
+
- 宿主不支持 MCP Apps 时只显示 `content` 文本,所以带界面的工具也要返回可读的文本摘要。
|
|
191
|
+
- 一个工具配一个专注的界面。一个界面同时伺候多个工具的结果,每次渲染都要先判断数据从哪来,模型也更难预期结果长什么样。
|
|
192
|
+
|
|
193
|
+
## 调试
|
|
194
|
+
|
|
195
|
+
- `mcp_inspect` `read_resource` 读 `ui://…`:成功返回 `text/html;profile=mcp-app` 与正文开头;报「读取 MCP Apps 界面产物失败」→ `dist/mcp-ui/<entry>.html` 不存在:入口名与 `readMcpUiTemplate('<entry>')` 不一致、`server/mcp/ui/<entry>/index.html` 缺失、或 dev server 还没重建,看 dev server 日志里 `[mcp-ui]` 前缀的行。构建失败行里出现「MCP UI 不能引用…」是依赖越界,按上文边界规则改 import,不改预设。
|
|
196
|
+
- 界面逻辑用 `mcp_inspect` 看不到;先用 `call` 确认 `structuredContent` 形状正确,再在预览里看渲染。宿主渲染失败时保留工具结果,只重试界面。
|
|
197
|
+
- 界面里调 `callServerTool` 失败,错误也走 server 日志:按 Trace-ID 查。
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
# 工具类写法
|
|
2
|
+
|
|
3
|
+
API 形状以 `node_modules/@lark-apaas/nestjs-mcp/dist/index.d.ts` 为准。下面是当前版本的写法与约束;字段名对不上时以 d.ts 为准。
|
|
4
|
+
|
|
5
|
+
## 工具面规模
|
|
6
|
+
|
|
7
|
+
工具面不是越全越好:每个工具的 schema 在消费方 Agent 的**每一轮**都占上下文,面越大它选错越多。
|
|
8
|
+
|
|
9
|
+
| 工具数 | 做法 |
|
|
10
|
+
|---|---|
|
|
11
|
+
| ≤ 15 | 一个动作一个工具 |
|
|
12
|
+
| 15–30 | 仍可用;把只有参数差别的近重复工具并掉 |
|
|
13
|
+
| > 30 | 收敛成「检索 + 执行」几个通用工具,高频的三五个再单独开 |
|
|
14
|
+
|
|
15
|
+
用户没说清要开放哪些能力、给谁用时,先问清再写,不要把 Service 层能做的全铺出去。
|
|
16
|
+
|
|
17
|
+
## 要不要界面
|
|
18
|
+
|
|
19
|
+
结果要用户动手挑、看、或确认的工具才配界面;只是给模型继续推理的数据不配。要配的话,界面类型与写法见 `mcp-apps.md`。
|
|
20
|
+
|
|
21
|
+
## 骨架
|
|
22
|
+
|
|
23
|
+
```typescript
|
|
24
|
+
// server/mcp/tools/order.tools.ts
|
|
25
|
+
import { z } from 'zod';
|
|
26
|
+
import {
|
|
27
|
+
McpTools,
|
|
28
|
+
McpTool,
|
|
29
|
+
McpToolError,
|
|
30
|
+
type Infer,
|
|
31
|
+
type McpContext,
|
|
32
|
+
type McpToolResult,
|
|
33
|
+
} from '@lark-apaas/fullstack-nestjs-core';
|
|
34
|
+
import { OrderService } from '@server/modules/order/order.service';
|
|
35
|
+
|
|
36
|
+
// schema 提成常量:方法签名用 Infer<typeof …> 引用
|
|
37
|
+
export const SearchOrdersInput = {
|
|
38
|
+
keyword: z.string().optional().describe('客户名关键字,模糊匹配'),
|
|
39
|
+
limit: z.number().int().min(1).max(50).default(20).describe('单页条数'),
|
|
40
|
+
cursor: z.string().optional().describe('上一页返回的 nextCursor;首页不传'),
|
|
41
|
+
};
|
|
42
|
+
export const SearchOrdersOutput = {
|
|
43
|
+
items: z.array(
|
|
44
|
+
z.object({
|
|
45
|
+
id: z.string(),
|
|
46
|
+
customer: z.string(),
|
|
47
|
+
amount: z.number().describe('金额,单位元'),
|
|
48
|
+
status: z.enum(['pending', 'paid', 'cancelled']),
|
|
49
|
+
}),
|
|
50
|
+
),
|
|
51
|
+
nextCursor: z.string().nullable().describe('还有下一页时非空,原样传回 cursor'),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const CancelOrderInput = { orderId: z.string().describe('订单 ID,来自 order_search 的 items[].id') };
|
|
55
|
+
export const CancelOrderOutput = { orderId: z.string(), status: z.literal('cancelled') };
|
|
56
|
+
|
|
57
|
+
@McpTools({ prefix: 'order_' })
|
|
58
|
+
export class OrderMcpTools {
|
|
59
|
+
constructor(private readonly orders: OrderService) {}
|
|
60
|
+
|
|
61
|
+
@McpTool({
|
|
62
|
+
title: '查询订单',
|
|
63
|
+
description:
|
|
64
|
+
'按客户名关键字分页查询当前用户可见的订单。用户想找订单、看订单列表时调用;一页最多 50 条,超出用 cursor 翻页。',
|
|
65
|
+
inputSchema: SearchOrdersInput,
|
|
66
|
+
outputSchema: SearchOrdersOutput,
|
|
67
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
68
|
+
})
|
|
69
|
+
async search(
|
|
70
|
+
input: Infer<typeof SearchOrdersInput>,
|
|
71
|
+
ctx: McpContext,
|
|
72
|
+
): Promise<McpToolResult<typeof SearchOrdersOutput>> {
|
|
73
|
+
const userId = ctx.user.userId!; // requireUser 默认开启,执行到这里必有值;取一次往下传
|
|
74
|
+
const page = await this.orders.search({ ...input, userId });
|
|
75
|
+
return {
|
|
76
|
+
// 按 outputSchema 挑字段,不把 Service 查出来的整行记录原样回传
|
|
77
|
+
structuredContent: {
|
|
78
|
+
items: page.items.map((o) => ({ id: o.id, customer: o.customer, amount: o.amount, status: o.status })),
|
|
79
|
+
nextCursor: page.nextCursor,
|
|
80
|
+
},
|
|
81
|
+
content: [{ type: 'text', text: `找到 ${page.items.length} 条订单` }],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
@McpTool({
|
|
86
|
+
title: '取消订单',
|
|
87
|
+
description:
|
|
88
|
+
'取消一个待支付订单。仅在用户明确要求取消、且订单状态为 pending 时调用;订单不存在返回 ORDER_NOT_FOUND,状态不允许返回 ORDER_NOT_CANCELLABLE。重复取消同一订单返回同一结果。',
|
|
89
|
+
inputSchema: CancelOrderInput,
|
|
90
|
+
outputSchema: CancelOrderOutput,
|
|
91
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
|
|
92
|
+
})
|
|
93
|
+
async cancel(
|
|
94
|
+
input: Infer<typeof CancelOrderInput>,
|
|
95
|
+
ctx: McpContext,
|
|
96
|
+
): Promise<McpToolResult<typeof CancelOrderOutput>> {
|
|
97
|
+
const userId = ctx.user.userId!;
|
|
98
|
+
const order = await this.orders.findVisibleTo(input.orderId, userId);
|
|
99
|
+
if (!order) throw new McpToolError('订单不存在,用 order_search 按客户名重查一个有效 ID', { code: 'ORDER_NOT_FOUND' });
|
|
100
|
+
if (order.status === 'cancelled') return { structuredContent: { orderId: order.id, status: 'cancelled' } };
|
|
101
|
+
if (order.status !== 'pending') {
|
|
102
|
+
throw new McpToolError(`订单状态为 ${order.status},只有 pending 能取消,不要重试`, { code: 'ORDER_NOT_CANCELLABLE' });
|
|
103
|
+
}
|
|
104
|
+
await this.orders.cancel(order.id, userId);
|
|
105
|
+
return { structuredContent: { orderId: order.id, status: 'cancelled' } };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
```typescript
|
|
111
|
+
// server/modules/order/order.module.ts —— 工具类和它依赖的 Service 注册在同一个模块
|
|
112
|
+
import { Module } from '@nestjs/common';
|
|
113
|
+
// OrderController / OrderService / OrderMcpTools 的 import 略
|
|
114
|
+
|
|
115
|
+
@Module({
|
|
116
|
+
controllers: [OrderController],
|
|
117
|
+
providers: [OrderService, OrderMcpTools],
|
|
118
|
+
})
|
|
119
|
+
export class OrderModule {}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
`@McpTools()` 自带 `@Injectable()`。SDK 启动时用 DiscoveryService 扫所有 provider;类没进任何模块的 `providers` 就扫不到它,启动日志不会有任何提示,工具静默缺席——启动日志「已注册 N 个 MCP 工具:…」列出的名字才是真相,对不上就是没注册。工具类及其依赖必须是单例:request / transient 作用域会在启动时抛「MCP 工具必须使用单例及单例依赖」,整个应用起不来。单例意味着所有请求共用一个实例:当前用户、本次入参、分页游标只放方法内的局部变量,写进类字段或模块级变量会在并发请求间串号。
|
|
123
|
+
|
|
124
|
+
## 装饰器选项
|
|
125
|
+
|
|
126
|
+
`@McpTool(options)`:
|
|
127
|
+
|
|
128
|
+
| 字段 | 说明 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| `name?` | 默认取方法名;最终工具名 = `prefix + name`,需匹配 `^[A-Za-z0-9_.-]{1,128}$`,全应用唯一 |
|
|
131
|
+
| `title?` | 面向人的展示名,面板里显示 |
|
|
132
|
+
| `description` | 必填,面向模型 |
|
|
133
|
+
| `inputSchema?` / `outputSchema?` | zod:原始形状 `{ a: z.string() }` 或 `z.object({...})` 都行;顶层必须是对象 |
|
|
134
|
+
| `annotations?` | `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`,四项都写 |
|
|
135
|
+
| `ui?` | `{ resourceUri: 'ui://…', visibility?: ['model' \| 'app'] }`,见 `mcp-apps.md` |
|
|
136
|
+
| `meta?` | 额外 `_meta`,与 `ui` 生成的字段合并 |
|
|
137
|
+
|
|
138
|
+
`@McpTools({ prefix? })`:给类下所有工具名加前缀,如 `order_`。
|
|
139
|
+
|
|
140
|
+
## 方法签名与返回值
|
|
141
|
+
|
|
142
|
+
- 固定 `(input, ctx)`。无入参的工具 `input` 为 `Record<string, never>`,仍要写第二个参数 `ctx`。
|
|
143
|
+
- 声明了 `outputSchema`:返回 `{ structuredContent }`,类型由 schema 推导;`content` 可选,SDK 会自动补一份 JSON 文本给不支持结构化输出的客户端。加一句业务摘要的 `content` 让文本客户端也能读。
|
|
144
|
+
- 未声明 `outputSchema`:返回 `{ content: [{ type: 'text', text }] }`;`content` 还支持 `image` / `audio` / `resource_link` 块。
|
|
145
|
+
- `isError: true` 由 SDK 在捕获 `McpToolError` 时设置,不需要手动返回。
|
|
146
|
+
|
|
147
|
+
## 返回体积
|
|
148
|
+
|
|
149
|
+
SDK 不截断,返回多少消费方 Agent 就吃多少,超了是它的上下文被挤掉。`mcp_inspect` 里看到的截断与落盘是调试通道的显示预算,与工具该返回多少无关。
|
|
150
|
+
|
|
151
|
+
- `structuredContent` 只放对方推理或界面渲染要用的字段。Service 查出来的整行记录、外键、审计字段先挑一遍再返回。
|
|
152
|
+
- 列表工具 `limit` 默认不超过 20、上限写进 zod(`.max(50)`);要更多靠 `cursor` 翻页,不靠调大 `limit`。
|
|
153
|
+
- 结果仍可能很大时自己截断,把截断标记和出路一起返回,让对方知道怎么缩小范围。两个字段都要写进 `outputSchema`,否则与声明对不上;位置放在数据字段**前面**,按尾部截断时先没的才是数组尾巴而不是这两个字段:
|
|
154
|
+
|
|
155
|
+
```typescript
|
|
156
|
+
return {
|
|
157
|
+
structuredContent: {
|
|
158
|
+
truncated: true,
|
|
159
|
+
hint: '命中 847 条,只返回前 20 条;加 status 过滤或换更具体的关键字',
|
|
160
|
+
items: page.items,
|
|
161
|
+
},
|
|
162
|
+
content: [{ type: 'text', text: '命中 847 条,返回前 20 条' }],
|
|
163
|
+
};
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
- 图片、附件、长文本用 `content` 里的 `resource_link` 给指针,不内联进结果。`mcp_inspect` 只渲染文本块,这类块在调试结果里不显示,别据此以为工具什么都没返回。
|
|
167
|
+
|
|
168
|
+
## McpContext
|
|
169
|
+
|
|
170
|
+
| 字段 | 用途 |
|
|
171
|
+
|---|---|
|
|
172
|
+
| `user` | 与 REST 里的 `req.userContext` 同源:`userId` / `tenantId` / `roles` / `env`(`preview` \| `runtime`)等,字段全部可选(`userId?: string`)。默认 `requireUser: true`,缺 `userId` 时 SDK 直接返回 `MCP_USER_REQUIRED`、方法不执行,所以方法体开头 `const userId = ctx.user.userId!;` 取一次往下传;不要为迁就类型去改 Service 签名 |
|
|
173
|
+
| `request` | 原始 HTTP 请求,读 header、logid |
|
|
174
|
+
| `signal` | 客户端取消时触发,长任务把它传给下游 |
|
|
175
|
+
| `requestId` | MCP 请求 id |
|
|
176
|
+
| `extra` | SDK 的 `RequestHandlerExtra`(`sessionId` / `requestId` / `authInfo` 等)。它有 `sendNotification`,但端点是无状态 JSON 模式,`notifications/progress` 发不到客户端——长任务别靠进度通知续命,拆成「发起 + 查状态」两个工具 |
|
|
177
|
+
|
|
178
|
+
工具执行中途要不到用户输入:SDK 没有 elicitation,无状态 JSON 模式下服务端发往客户端的请求也出不去(与进度通知同因)。需要用户确认或补参数时,把它做成入参交给模型去问,或者做确认型界面(见 `mcp-apps.md`),不要设计成「工具跑一半停下来等人」。
|
|
179
|
+
|
|
180
|
+
## 错误处理
|
|
181
|
+
|
|
182
|
+
- 可预期的业务失败抛 `new McpToolError(message, { code?, data? })`:SDK 转成 `isError: true`,`message` / `code` / `data` 原样进 Agent 上下文。`code` 用 `SCREAMING_SNAKE`,并在 `description` 里预告,Agent 才知道怎么应对。
|
|
183
|
+
- `message` 写成「哪里错了 + 下一步做什么」:「订单不存在,用 order_search 按客户名重查一个有效 ID」。错误消息必达,应用 Skill 里的处理说明要消费方主动取 Prompt 才看得到,出路别只写在那边。
|
|
184
|
+
- 其他异常:SDK 记日志、返回通用失败提示,Agent 看不到原因。调试时看到「通用失败提示」就去读 Trace-ID 日志。
|
|
185
|
+
- 入参校验交给 zod,方法体不重复判空;zod 失败由 SDK 返回,方法不执行。
|
|
186
|
+
|
|
187
|
+
## description 怎么写
|
|
188
|
+
|
|
189
|
+
消费方 Agent 只靠 `description` 与字段 `.describe()` 决定调不调、怎么填。写全五件事:用途、何时调用(含前置条件)、参数从哪来(「来自 order_search 的 items[].id」)、返回什么、失败会返回哪些 code。写操作再加一句幂等语义(「重复取消返回同一结果」)。
|
|
190
|
+
|
|
191
|
+
字段描述写单位、格式、取值来源;枚举用 `z.enum`;入参不用 `z.any()` / `z.unknown()`。
|
|
192
|
+
|
|
193
|
+
## 开通(首次)
|
|
194
|
+
|
|
195
|
+
1. 前置检查通过(见 SKILL.md)。
|
|
196
|
+
2. 建 `server/mcp/tools/<域>.tools.ts`,至少一个只读工具复用现有 Service。
|
|
197
|
+
3. 把工具类加进所属业务模块的 `providers`。
|
|
198
|
+
4. 写 `server/mcp/skills/<name>/SKILL.md`(下节)。
|
|
199
|
+
5. 跑 `lark-cli apps +mcp-key-create --app-id "$app_id"` 备好运行态凭证。
|
|
200
|
+
6. 进 `debugging.md` 的调试回路。
|
|
201
|
+
|
|
202
|
+
## 应用 Skill:`server/mcp/skills/<name>/SKILL.md`
|
|
203
|
+
|
|
204
|
+
给消费方 Agent 的使用说明。一个业务流程一个目录,目录里只认固定文件名 `SKILL.md`。SDK 把每份注册为同名 MCP Prompt:`prompts/list` 给 `name` / `description`,`prompts/get` 返回去掉 frontmatter 的正文(一条 `role: user` 文本消息)。它不是 Resource、没有 `skill://` URI,`resources/list` 里看不到它是正常的。每次请求重新读文件,改完不用重启。
|
|
205
|
+
|
|
206
|
+
frontmatter 规则。SDK 每次请求都会校验,不合法时整个 MCP 请求 500,不会静默跳过这一份:
|
|
207
|
+
|
|
208
|
+
- `name` 必填且等于目录名:1–64 位小写字母、数字与单个连字符,不能首尾连字符、不能连续 `--`。
|
|
209
|
+
- `description` 必填非空,不超过 1024 字符;消费方只靠它决定要不要取这份 Prompt,写清适用场景。
|
|
210
|
+
- 全应用不重名;最多 100 份,单文件不超过 1 MiB,合计不超过 8 MiB;必须是工程内普通文件,不接受符号链接。
|
|
211
|
+
- 只有 `SKILL.md` 会被读取和交付,同目录的图片、附件不会。
|
|
212
|
+
|
|
213
|
+
正文必含:适用场景、实际工具名与关键参数、调用顺序、需要用户确认的边界、身份与权限要求、每个错误 code 的处理、界面 URI(如有)。只写已实现的能力;不写凭证、用户数据、内部实现。
|
|
214
|
+
|
|
215
|
+
示例 `server/mcp/skills/orders/SKILL.md`。`---` 必须是文件第一行,前面不能有任何字符,注释也不行:
|
|
216
|
+
|
|
217
|
+
```markdown
|
|
218
|
+
---
|
|
219
|
+
name: orders
|
|
220
|
+
description: 查询当前用户可见的订单,并在用户明确要求后取消待支付订单。
|
|
221
|
+
---
|
|
222
|
+
# 订单能力
|
|
223
|
+
- 需要已登录用户身份;只返回当前用户可见的订单。
|
|
224
|
+
- 找订单:order_search,keyword 模糊匹配客户名;nextCursor 非空时用 cursor 翻页。
|
|
225
|
+
- 取消订单:只有用户明确要求取消、且 status 为 pending 时调用 order_cancel;orderId 取自 order_search 的 items[].id。
|
|
226
|
+
- ORDER_NOT_FOUND:请用户核对订单 ID。ORDER_NOT_CANCELLABLE:告知当前状态,不重试。
|
|
227
|
+
- 支持界面的宿主会用 ui://order/list 展示查询结果。
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
## 定义校验
|
|
231
|
+
|
|
232
|
+
工具名格式与重名、资源 URI 的 `ui://` 前缀与重名、`ui.resourceUri` 能否找到对应的 `@McpUiResource()`——这些在应用启动时统一校验,失败直接阻断启动并打印「MCP 定义校验失败」,逐条列出问题与出错的类和方法。一个方法不能同时标 `@McpTool()` 与 `@McpUiResource()`。改完等 dev server 重启看日志即可,没有单独的离线校验命令。
|
|
233
|
+
|
|
234
|
+
## 模块配置
|
|
235
|
+
|
|
236
|
+
```typescript
|
|
237
|
+
PlatformModule.forRoot({
|
|
238
|
+
mcp: {
|
|
239
|
+
serverName: 'travel-approval', // 首次开通 MCP 时按用途设置,别留缺省
|
|
240
|
+
serverVersion: '1.0.0',
|
|
241
|
+
instructions: '…', // initialize 响应里的说明
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
// 关闭端点:PlatformModule.forRoot({ mcp: false })
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**`serverName` 首次开通时就设上。** `initialize` 把它返回给连上来的外部 Agent,`mcp_inspect` 的 `list_tools` 也显示它;不设则是 `miaoda-app`,谁看都不知道是哪个应用。取材于本 MCP 做什么(差旅审批 → `travel-approval`,库存查询 → `inventory`),小写字母 + 数字 + `-`/`_`,两三个词以内。
|
|
248
|
+
|
|
249
|
+
其余项一般不动。`requireUser` 保持默认 `true`。零工具、零资源、无应用 Skill 时端点返回 404,这是正常状态不是故障。
|