@lark-apaas/coding-steering 0.1.32-dev.7b2c2ad → 0.1.32-dev.a230ed0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lark-apaas/coding-steering",
3
- "version": "0.1.32-dev.7b2c2ad",
3
+ "version": "0.1.32-dev.a230ed0",
4
4
  "description": "Stack-specific steering content for miaoda-coding templates",
5
5
  "type": "module",
6
6
  "files": [
@@ -19,7 +19,7 @@ match-template-name: nestjs-react-fullstack
19
19
  项目提供了dataloom SDK的文件服务,用于文件的上传,下载,删除,列出bucket中文件,创建临时签名url等能力。
20
20
 
21
21
  ## 使用注意
22
- - **入口边界**:本 SDK 是**应用前端代码**读写应用存储的唯一入口。Agent 自己在对话 / 开发中上传、灌数据或调试文件请用 `miaoda file` CLI(见 `miaoda-file` skill)——二者操作同一个应用存储桶,但 **`miaoda file` CLI 禁止写进页面代码**,页面里一律用本 SDK。
22
+ - **入口边界**:本 SDK 是**应用前端代码**读写应用存储的唯一入口。Agent 自己在对话 / 开发中上传、灌数据或调试文件,先加载应用文件存储操作 skill(按「应用文件存储 / 文件上传 / 文件下载」召回),用其 CLI 命令操作,具体命令一律以该 skill 为准——二者操作同一个应用存储桶,但**该 CLI 命令只供 Agent 在沙箱终端使用,禁止写进页面代码**,页面里一律用本 SDK。
23
23
  - @lark-apaas/client-toolkit/dataloom 这个SDK只适用于前端调用,禁止在服务端调用
24
24
  - 上传成功后,最重要的返回值是 `data.download_url`。需要将此URL保存到你的业务数据库中
25
25
  - **⚠️ 场景区分(重要)**:`dataloom.storage` 仅适用于需要持久化存储文件或获取 `download_url` 保存到数据库的场景。如果文件仅作为插件输入(传给 `capabilityClient`),**必须直接传 File/Blob 对象,禁止先走 dataloom 上传再传 URL**;插件调用(capability)不属于 dataloom,详见 plugin-guide
@@ -4,12 +4,11 @@ description: Use when planning, adding, debugging, or reviewing NestJS cache log
4
4
  steering: true
5
5
  steering-topic: nestjs_cache
6
6
  match-template-name: nestjs-react-fullstack
7
- control-by-feature-ab: true
8
7
  ---
9
8
 
10
9
  # NestJS Caching 使用指南
11
10
 
12
- 在 NestJS 后端为读多写少路径加缓存。平台已注入 `CACHE_MANAGER`,业务只需要:**判断该不该缓存 → 选一种使用姿势 → 遵守 Key 命名和 TTL 规范**。
11
+ 本 Skill 用于妙搭 NestJS 后端的缓存评估、接入、优化、审查和排障。先判断数据能否复用、谁可以读取、允许多旧,再确定 Key、TTL、失效方式和实现。平台已注入 `CACHE_MANAGER`,业务直接使用。触发本 Skill 只表示需要评估缓存;收益不足或正确性条件不满足时,说明原因并给出合适的处理方向。
13
12
 
14
13
  ## 前置约束:不要自己装配 `CacheModule`
15
14
 
@@ -44,40 +43,45 @@ export class BookModule {}
44
43
  ├─ 是否满足以下任一"值得缓存"信号?
45
44
  │ a) 读得频繁(同一份数据反复被查,非一次性接口)
46
45
  │ b) 读得慢(源侧要跨服务/跨库/跑聚合,用户能感觉到等待)
47
- │ c) 读得贵(触发外部收费 API / 高负载 RPC / 大结果集 SQL)
46
+ │ c) 读得贵(外部 HTTP 服务、多维表格等插件、AI 模型调用、高负载 RPC / 大结果集 SQL)
48
47
  │ ├─ 都不满足 → 不加。加了维护成本 > 收益
49
48
  │ └─ 满足任一 → 下一步
50
- ├─ 是否是**授权敏感路径**?
51
- │ a) 该 handler / 方法被 `@CanRole` / `@Can` 装饰
52
- │ b) 返回值由 PostgreSQL RLS(Row Level Security)过滤(登录身份不同结果不同)
53
- │ ├─ 是 → 不加。授权决策 / RLS 过滤后的结果与身份强绑定,
54
- │ │ 缓存 = 越权(陈旧 policy)或串号(不同用户读同一 key)
55
- │ └─ 否 → 下一步
49
+ ├─ 返回数据是否受身份或权限影响?
50
+ │ ├─ 否 → 下一步
51
+ │ └─ 是 → 每次请求仍须执行必要鉴权;缓存命中不能跳过方法内的权限检查
52
+ │ 身份相关结果必须隔离,权限变化后旧结果不得越权访问
53
+ │ 无法满足时 → 不缓存该结果;继续评估可独立复用的公共数据
56
54
  ├─ 业务可容忍 TTL 期内数据轻微陈旧(读到几秒前的旧值不影响正确性)?
57
55
  │ ├─ 否(要强一致,如库存/余额/权限当次判定)→ 不加
58
56
  │ └─ 是 → 下一步
59
- ├─ 数据是"读多写少"(读远多于写,写触发时能明确 invalidate 哪些 key)?
60
- │ ├─ 否(写路径同样频繁、或 invalidate 范围爆炸)→ 不加,命中率上不去
61
- │ └─ 是 → 加缓存,选一种姿势(见下方)
62
- └─ 是否可能出现热点 key(一份数据被大量请求同时命中,如首页配置、公共字典)?
63
- └─ 是 → 必须用 `cache.wrap` 拿 single-flight 语义,防击穿
57
+ ├─ 数据复用收益是否高于缓存维护成本?
58
+ │ 结合重复读取量、更新频率、允许的陈旧时间和失效成本判断;读多写少是优先信号
59
+ │ ├─ 否,或无法满足一致性要求 → 不加
60
+ │ └─ 是 → 可加;写入较频繁时,仍需确认失效方式可维护、预期命中率有价值
61
+ └─ 是否存在同一 Key 的并发回源?
62
+ └─ 是 → 使用 `cache.wrap` 合并单进程内并发;多实例仍可能分别回源,继续评估源侧承载能力
64
63
  ```
65
64
 
66
- **判断技巧**:拿不准就问自己——"如果同一个用户 1 秒内连点 5 次,我希望后 4 次直接返回上次结果吗?" 是 → 加;否(每次必须重算)→ 不加。
65
+ **判断技巧**:先确认相同输入是否会重复读取,再检查身份隔离、允许的陈旧时间和失效方式。连续请求可以返回相同结果,只是复用信号,不能替代这些检查。接口慢时也要检查索引、重复查询等源侧问题。
67
66
 
68
- **典型该加**:外部 open API 元数据 / 长尾字典表 / 用户 profile / 频繁读取的配置 / 慢报表聚合结果。
67
+ **优先评估**:外部 API 元数据、公共字典、配置、热点详情、允许陈旧的报表,以及满足身份隔离和权限变化要求的用户资料。一个接口包含多步读取时,分别评估各步骤。
69
68
 
70
- **典型不该加**:金融流水、库存扣减、鉴权决策、事务型写路径、每次都不同参数的即席查询。
69
+ **不使用缓存替代**:库存扣减、余额校验、授权决定等当次权威判断,以及事务型写入。历史记录和统计展示可单独评估;参数不重复、复用收益低的查询通常不加。
71
70
 
72
71
  ## Quick Reference
73
72
 
74
73
  | 场景 | 用法 |
75
74
  |------|------|
76
- | 读一次+回填 | `await cache.get(key)` → miss 时 fetch → `cache.set(key, val, ttlMs)` |
77
- | 热点 key 防击穿 | `await cache.wrap(key, () => loader(), ttlMs)` |
78
- | 幂等 GET 接口一键缓存 | `@UseInterceptors(CacheInterceptor)` + `@CacheKey` + `@CacheTTL` |
79
- | 写路径失效缓存 | `await cache.del(key)` |
80
- | TTL 单位 | 毫秒(cache-manager v6 起) |
75
+ | 单次读缓存、未命中回源 | `get` → 未命中时读取源数据 → `set`;需并发合并时用 `wrap` |
76
+ | 公共配置、字典、热点数据 | 使用平台共享缓存;`wrap` 仅合并单进程内对同一 Key 的并发读取 |
77
+ | 外部 HTTP / 多维表格等插件 / AI 模型调用结果 | 只有 Server 侧调用能用 `CACHE_MANAGER`,调用放在哪一侧按 plugin-guide 决定;Key 用规范化后的完整请求参数(AI 另加实例 id 与模型参数);只缓存成功的最终结果,超时、错误、流式过程不缓存;缓存响应数据,不缓存凭证;外部数据靠 TTL 发现变化,AI 结果仅在相同输入可复用、且不是「重新生成」流程时缓存 |
78
+ | 可缓存的 GET 返回值 | `CacheInterceptor` + `CacheTTL`;静态 Key 仅用于所有相关请求结果相同的场景 |
79
+ | 用户或租户维度的数据 | 先确认鉴权、身份隔离和权限变化要求,再选择手动缓存或受支持的动态 Key |
80
+ | 列表、筛选、分页、报表 | Key 纳入影响结果的身份与有效查询参数;估算容量并明确失效方式 |
81
+ | 反复读取不存在的资源 | 满足新数据可见性要求时缓存带类型标记的空结果;不缓存暂时故障 |
82
+ | 一个接口包含多步读取 | 分别评估公共数据、用户数据和计算结果,只缓存满足条件的部分 |
83
+ | 写后失效 | 业务数据提交成功后 `del` 相关 Key,并评估并发旧值回填 |
84
+ | TTL 单位 | 毫秒;同时遵循平台允许的范围 |
81
85
 
82
86
  ## 三种使用姿势
83
87
 
@@ -112,9 +116,9 @@ export class ProfileService {
112
116
 
113
117
  ### B. `cache.wrap`(防击穿 / single-flight)
114
118
 
115
- 热点 key 同时被 N 个请求命中时,只有一个 loader 会真正执行,其他并发请求等它结果。冷启动场景必须用这个而不是 A 方案。
119
+ 对同一缓存实例和 Key,`cache.wrap` 会合并同一进程内重叠的读取:只有一个 loader 执行,其他请求等待结果。热点或冷启动出现并发回源时优先使用。
116
120
 
117
- > **限定**:`wrap` 的 single-flight 是**单实例进程内**去重(v6 内嵌 `promise-coalesce`,本质是内存 Map)。多 pod 部署时每个实例的首个请求各自触发一次 loader → 最多 N 个 pod 并发回源 N 次,而非全局 1 次。要做真正的全局防击穿需额外配分布式锁。
121
+ > **边界**:不同 FaaS 实例各自合并,`wrap` 不保证全局只回源一次。若源侧仍无法承受多实例并发,应根据平台已有能力选择进一步措施;不要假设 `CACHE_MANAGER` 自带分布式锁。
118
122
 
119
123
  ```typescript
120
124
  async getConfig(appApiName: string) {
@@ -128,9 +132,9 @@ async getConfig(appApiName: string) {
128
132
 
129
133
  ### C. `CacheInterceptor` + 装饰器(Controller 方法级)
130
134
 
131
- 只适合**幂等 GET** 且返回值 JSON-safe。写请求、带循环引用 / Buffer / Date 的返回值、需要按登录用户维度分 key 的场景,不要用它——用 A 方案。
135
+ 仅用于可缓存的 GET,返回值须为 JSON-safe,缓存命中不能跳过必要鉴权或方法内的权限检查。默认按 URL 生成 Key,不会自动包含登录身份。身份相关结果须满足前述隔离与权限变化要求,再选择手动缓存或动态 Key。
132
136
 
133
- **`@CacheKey` 是静态常量**:所有请求都命中同一条缓存,会忽略路由参数 / query。带路由参数(如 `:apiName`)时**不要写 `@CacheKey`**,让 interceptor 按完整 URL 自动分 key;否则 app A 的结果会被返回给 app B(串号)。
137
+ 当前 `@nestjs/cache-manager@3.1.3` 的 `@CacheKey` 支持字符串和 Key 工厂函数。字符串 Key 是固定值,仅适用于相关请求结果相同的场景;动态 Key 应包含影响结果的路由、查询与身份维度。无身份等额外差异时,可不写 `@CacheKey` 并沿用 URL Key。已有项目先确认版本支持;示例中的 URL 方案仍可使用。
134
138
 
135
139
  ```typescript
136
140
  import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
@@ -151,13 +155,13 @@ export class AppsController {
151
155
  |------|------|------|
152
156
  | 业务前缀必需 | `profile:${userId}` | 避免多应用共用 Redis 时 key 冲突 |
153
157
  | 冒号分层 | `app:${apiName}:config:${env}` | 便于排障时 SCAN |
154
- | 参数拼接稳定 | 多参数按字典序拼接 | 避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
158
+ | 参数拼接稳定且完整 | 筛选、排序、分页等有效参数按固定字段顺序编码;按需加入可信的身份维度 | 相同条件得到相同 Key,不同结果不能因遗漏参数而共用 Key |
155
159
  | 禁用大对象作 key | 不要 `JSON.stringify(user)` 当 key | key 过长 / 特殊字符转义踩坑 |
156
160
  | Key 长度硬上限 | 必须 ≤ 512 字符 | 平台校验,超过直接跳过缓存 |
157
161
 
158
162
  ## 平台校验限制(硬门槛)
159
163
 
160
- 平台会在缓存读写时做以下校验。**超过限制不会抛异常,而是本次请求跳过缓存 → 原读取逻辑正常执行 → Trace 记录 error_code**。写代码时按这套边界规划 key / TTL / value:
164
+ 以下是当前平台规定的 Key、TTL、数量和 Value 边界,业务代码按这些限制设计。缓存失败或拒写时,业务应使用正常回源结果;具体拒绝行为和 Trace 字段以部署版本的 SDK 与服务端契约为准。排障时核对实际错误,不仅凭本表推断原因。
161
165
 
162
166
  | 限制项 | 边界 | 超限行为 | error_code |
163
167
  |--------|------|----------|------------|
@@ -193,16 +197,16 @@ cache.get(key) 场景
193
197
  |------|----------|
194
198
  | Key 不加业务前缀 | 必须 `${业务域}:${resource}:${id}` 分层,避免多应用 / 多资源撞车 |
195
199
  | 空字符串 / `null` / `undefined` 当 key | 平台校验空 key,本次跳过缓存(`CACHE_KEY_INVALID`);调用前先判空 |
196
- | 用户输入直接拼 key(`search:${rawQuery}`) | rawQuery 可能超 512 字符(`CACHE_KEY_TOO_LONG`);先 hash 或截断 |
200
+ | 用户输入直接拼 Key,或截断后作为 Key | 先规范化影响结果的完整参数,再计算摘要;不要用截断代替摘要。摘要只缩短 Key,不减少不同参数组合的数量,仍需评估平台配额 |
197
201
  | 把大对象 / JSON / SQL 塞进 key | 长度爆炸 + 特殊字符转义踩坑;用结构化 id 或摘要 |
198
202
  | Key 里放 traceId / timestamp / requestId | key 空间爆炸,撞 10,000 上限后新 key 不再写入(`CACHE_KEY_QUOTA_EXCEEDED`) |
199
- | 多参数拼接顺序不稳定 | 多参数按字典序拼接,避免 `?a=1&b=2` vs `?b=2&a=1` 命中不同 key |
203
+ | 多参数拼接顺序不稳定或遗漏有效参数 | 固定字段顺序,包含所有影响结果的参数;规范化不能改变查询含义,例如有顺序语义的数组不能随意排序 |
200
204
 
201
205
  ### TTL 相关(v6 单位是**毫秒**)
202
206
 
203
207
  | 错误 | 正确做法 |
204
208
  |------|----------|
205
- | TTL 传秒(v5 旧思维,`60` = 想要 1 分钟) | v6 起是毫秒,`60_000` 才是 1 分钟;且 `60` = 60ms 会被平台拒(`CACHE_TTL_OUT_OF_RANGE`) |
209
+ | TTL 单位误传为秒,例如用 `60` 表示 1 分钟 | `cache-manager` v5、v6 的 TTL 均以毫秒计,1 分钟应传 `60_000`。当前平台仍要求 TTL 在 `30_000 - 86_400_000` ms 内,`60` 不在允许范围内 |
206
210
  | TTL 传 `0` 想表达永久 | v6 `0` = 不过期,但平台也拒;接近永久用 `24 * 3600_000`(24h 上限) |
207
211
  | TTL 短于 30s | 平台拒(`CACHE_TTL_OUT_OF_RANGE`),本次不缓存;实际范围 `30_000 - 86_400_000` |
208
212
 
@@ -210,9 +214,9 @@ cache.get(key) 场景
210
214
 
211
215
  | 错误 | 正确做法 |
212
216
  |------|----------|
213
- | 缓存 Buffer / Date / 循环引用对象 | 序列化只保证 JSON-safe;复杂对象手动 toDTO |
214
- | 缓存 `null` 当"存在" | miss 就是 `null`,两者不可区分;表达"值就是空"用哨兵字符串(如 `'__EMPTY__'`)读到再转回 `null` |
215
- | 单 value 接近或超过 1 MB | 平台拒写(`CACHE_VALUE_TOO_LARGE`);先投影裁剪或按分页 key 拆开;大 blob 用 CDN / 对象存储 |
217
+ | 直接缓存 Buffer / Date / 循环引用对象 | 转成 JSON-safe DTO,并明确缓存命中与回源返回相同的数据结构;日期可转为约定的字符串,二进制使用文件地址 |
218
+ | 缓存 `null` 表示“资源不存在” | 用带类型标记的结构,如 `{ kind: 'not-found' }`,与 miss 区分。只有允许延迟发现新数据时才缓存;TTL 遵循平台范围,超时和服务错误不能写成“不存在” |
219
+ | 单 value 接近或超过 1 MB | 先裁剪字段或按分页 Key 拆分;大文件用 CDN / 对象存储。分页 Key 仍需纳入查询维度并估算数量 |
216
220
 
217
221
  ### API 姿势相关
218
222
 
@@ -220,18 +224,18 @@ cache.get(key) 场景
220
224
  |------|----------|
221
225
  | 业务模块里再 `CacheModule.register()` / `registerAsync(...)` | 平台已全局注册,直接 `@Inject(CACHE_MANAGER)`;重复注册会创建独立内存 store,不走 Redis、不 fail-open |
222
226
  | 用 `CacheInterceptor` 缓存写请求 | Interceptor 只适合幂等 GET;写请求用 A 方案,配 `cache.del(key)` |
223
- | `@CacheKey` 静态字符串包含用户维度,或带路由参数却写死 key | Interceptor 不感知登录态、静态 key 忽略路由参数/query → 串号;带用户或路由维度改用 A 方案手动拼 key |
227
+ | 不同路由、查询或身份结果共用一个静态 `@CacheKey` | 静态 Key 不会区分这些结果;无额外身份差异时可用 URL Key,否则使用包含必要维度的动态 Key 或手动缓存,并满足鉴权要求 |
224
228
  | 高 QPS 热点用 A 方案而非 `wrap` | 击穿风险,冷启动 loader 会被并发触发 N 次;换 B 方案拿 single-flight(注意仍是单实例内去重) |
225
- | 用 `cache.wrap` 包写路径 | wrap 是"读 + 回填"模型;写路径 `.set()` 后再 `.del()` |
226
- | `set` 之后立刻 `get` 期望读到 | 平台侧写入 fire-and-forget,**即便 `await set` 也不保证同请求/下一 tick 可见**;写完后直接返回本地变量 |
229
+ | 用 `cache.wrap` 包业务写入 | `wrap` 仅用于读取和回填。业务数据提交成功后,再 `cache.del(key)` 失效相关缓存;涉及详情和列表时分别确定相关 Key。删除缓存不能保证与并发读取强一致 |
230
+ | 把 `await cache.set()` 等同于业务数据提交,或默认它一定不等待写入 | 当前锁定 SDK 会等待底层 `set`;服务端写入可见性取决于后端契约。缓存不能作为业务提交依据;写后直接使用本次计算或保存得到的结果,不用立即 `get` 来证明业务成功 |
227
231
 
228
232
  ### 授权 / 身份相关(可能导致越权)
229
233
 
230
234
  | 错误 | 正确做法 |
231
235
  |------|----------|
232
- | 缓存 `@CanRole` / `@Can` 装饰的 handler 返回值 | 授权决策与登录身份耦合,缓存陈旧 → 该拒的放行 = 越权;直接不加缓存 |
233
- | 用 `CacheInterceptor` 缓存"读取当前登录用户可见列表"(背后走 PostgreSQL RLS 过滤) | RLS 按 `current_user` / `set_config` 注入的租户/身份过滤,Interceptor 不感知身份 → 用户 A 的结果被用户 B 读到 = 越权串号;这类接口不加缓存,或用 A 方案手动把 `userId` / `tenantId` 拼进 key |
234
- | 手动缓存 RLS 保护表的查询结果,key 里没带身份维度(如 `list:tasks`) | 同上,多用户共享同一 key → 串号;key 必须至少含 `${tenantId}:${userId}`,且注意 key 空间是否会撞 10,000 上限 |
236
+ | 看到 `@CanRole` / `@Can` 就直接拒绝缓存 | 每次请求保留必要鉴权;按返回数据的共享范围评估,命中不能跳过方法内的权限检查 |
237
+ | 用默认 URL Key 缓存按当前身份过滤的结果 | URL 不能表达身份;需从可信请求上下文取得租户、用户等必要维度,隔离结果并满足权限变化要求 |
238
+ | 认为 Key 带 `tenantId` / `userId` 就足够安全 | 身份隔离不能解决权限撤销;无法确保旧结果仍有权访问时,不缓存该结果。Key 数量仍须遵循平台配额 |
235
239
 
236
240
  ### 防御式代码
237
241
 
@@ -248,8 +252,14 @@ cache.get(key) 场景
248
252
  | 本地测缓存正常,线上不共享 / 重启就丢 | 业务模块里自己 `CacheModule.register()` 了,跑的是内存 store | 检查 `imports:` 是否含 `CacheModule.*`;删掉,直接 `@Inject(CACHE_MANAGER)` |
249
253
  | Interceptor 不生效 | 忘 `@UseInterceptors(CacheInterceptor)` 或 Controller 没注册 | 在 loader 加日志,观察是否每次请求都被调 |
250
254
  | TTL "不生效" | 传成秒、传 `0`、或超出 `30s - 24h` 平台范围 | Trace 里出现 `CACHE_TTL_OUT_OF_RANGE` 就是超范围 |
251
- | 数据陈旧 | 写路径没 `cache.del` 主动失效 | 更新时 log 出 del 的 key,比对读路径的 key |
255
+ | 数据陈旧 | 写后未失效、删除失败、遗漏相关 Key,或旧的并发读取在删除后又回填 | 核对写入提交、删除和回填的先后顺序,检查缓存错误与 Key 覆盖范围;需要强一致的读取不要依赖缓存 |
252
256
  | Trace 出现 `CACHE_KEY_TOO_LONG` | key 里塞了整段 SQL / 用户输入 / JSON | 用 hash 或结构化 id 替换 |
253
257
  | Trace 出现 `CACHE_VALUE_TOO_LARGE` | 整表 / 大 list 直接缓存 | 分页缓存或投影裁剪,把单个 value 压到 1 MB 以内 |
254
258
  | Trace 出现 `CACHE_KEY_QUOTA_EXCEEDED` | key 空间失控(含 traceId / timestamp / 用户输入) | 收敛 key 模板,用有限的枚举维度 |
255
- | 并发压测下源侧被打爆 | 热点未用 `wrap` | 换 A → B,加 single-flight |
259
+ | 并发压测下源侧被打爆 | 同一 Key 并发回源、Key 不稳定、缓存不可用或多实例同时冷启动 | 先核对 Key、命中与实例数;`wrap` 只合并单进程内并发,多实例压力需结合平台能力另行处理 |
260
+
261
+ ## 验证缓存行为
262
+
263
+ 按本次改动验证相关场景:命中时减少回源,未命中正常读取,`0` / `false` / 空字符串不被误判;影响结果的参数和身份维度正确隔离;写入后按约定失效;缓存不可用时正常回源且业务错误仍能暴露。
264
+
265
+ 涉及身份数据时,验证缓存命中不会跳过鉴权,并检查权限撤销后旧结果不可越权访问。涉及热点时区分单进程并发合并与多实例分别回源。无法运行的检查应说明原因,不将方案预期写成已验证结果。
@@ -11,7 +11,7 @@ match-template-name: nestjs-react-fullstack
11
11
  在 NestJS 服务端代码中使用 `FileService` 进行文件上传、下载、删除和管理。
12
12
 
13
13
  > **使用注意**: 仅用于服务端文件处理场景,非必要文件上传下载场景请使用前端 `client-builtins-file-storage-service` skill。
14
- > **入口边界**:本 SDK 是**服务端代码**读写应用存储的入口;Agent 在对话 / 开发中自己上传或调试文件用 `miaoda file` CLI(见 `miaoda-file` skill)。二者与本 SDK 操作同一个应用存储桶,但 **`miaoda file` CLI 禁止写进服务端代码**。
14
+ > **入口边界**:本 SDK 是**服务端代码**读写应用存储的入口;Agent 在对话 / 开发中自己上传或调试文件,先加载应用文件存储操作 skill(按「应用文件存储 / 文件上传 / 文件下载」召回),用其 CLI 命令操作,具体命令一律以该 skill 为准。二者与本 SDK 操作同一个应用存储桶,但**该 CLI 命令只供 Agent 在沙箱终端使用,禁止写进服务端代码**。
15
15
 
16
16
  ## 使用场景
17
17
 
@@ -0,0 +1,77 @@
1
+ ---
2
+ name: mcp-guide
3
+ description: "Use when 用户要把应用能力开放给外部 Agent / 工作伙伴 / MCP 客户端调用,新增或修改 MCP 工具,给工具结果加界面(MCP Apps / ui:// 资源),维护应用 Skill(server/mcp/skills/<name>/SKILL.md,SDK 注册为 MCP Prompt),或要在开发态核对、调用、调试 MCP 工具。触发词:MCP, MCP Server, MCP 工具, MCP Apps, ui://, @McpTools, @McpTool, @McpUiResource, McpToolError, readMcpUiTemplate, miaoda-mcp validate, .spark/mcp/manifest.json, tools/list, tools/call, resources/read, prompts/list, prompts/get, mcp_call, 开放给 Agent, 工作伙伴调用, Agent 调用应用, 应用 Skill, server/mcp, server/mcp/ui, server/mcp/skills。不负责 HTTP 对外开放接口(走 openapi-guide)、自动化触发器(走 trigger-guide)。"
4
+ steering: true
5
+ steering-topic: mcp_guide
6
+ match-template-name: nestjs-react-fullstack
7
+ gate-tools:
8
+ - tool: mcp_call
9
+ ---
10
+
11
+ # MCP 开发与调试
12
+
13
+ 应用的 MCP Server 是全栈工程里多出来的一层出口:同仓、同域名、同版本。SDK(`@lark-apaas/fullstack-nestjs-core` 重导出 `@lark-apaas/nestjs-mcp`)已经内置端点 `POST /__innerapi__/mcp`、Streamable HTTP 协议、zod 校验、身份读取、清单导出、`ui://` 资源与应用 Skill(MCP Prompt)下发。你只写三种文件:工具类、界面入口、应用 Skill。
14
+
15
+ **真值顺序**:工程内 `node_modules/@lark-apaas/nestjs-mcp/` 的 `README.md` 与 `dist/index.d.ts` > 本文。装饰器选项、类型名、返回值形状以 `index.d.ts` 为准;本文与它冲突时按包改,不按本文改。
16
+
17
+ ## 固定路径(不探测)
18
+
19
+ | 路径 | 内容 |
20
+ |---|---|
21
+ | `server/mcp/tools/<域>.tools.ts` | 工具类:一个业务域一个文件,`@McpTools()` 类 + 多个 `@McpTool()` 方法 |
22
+ | `server/mcp/ui/<entry>/index.html`(+ `main.tsx`)、`server/mcp/ui/tsconfig.json` | MCP Apps 界面源码:独立的浏览器代码,Vite 预设自动打成 `dist/mcp-ui/<entry>.html` |
23
+ | `server/mcp/skills/<name>/SKILL.md` | 应用 Skill:一个业务流程一份,给消费方 Agent 看的用法说明,SDK 注册为同名 MCP Prompt |
24
+ | `.spark/mcp/manifest.json` | SDK 生成的离线清单,随代码提交,不手改 |
25
+
26
+ `server/mcp/` 不存在即为首次开通;开通只需建上述文件并把工具类注册为所属业务模块的 provider。`McpModule` 已由 `PlatformModule.forRoot()` 引入,SDK 启动时自动发现工具类——`app.module.ts` 不动,不写注册表,不写 `tools/index.ts`。
27
+
28
+ 三份 Markdown 别混:本文(教你写代码)、`server/mcp/skills/*/SKILL.md`(应用产物,教下游 Agent 用工具)、SDK README(真值)。不把本文复制成应用 Skill,应用 Skill 不加 `steering: true`。
29
+
30
+ ## 前置检查(一条命令)
31
+
32
+ ```bash
33
+ ls -d node_modules/@lark-apaas/nestjs-mcp node_modules/@lark-apaas/coding-preset-vite-react/lib/plugins/mcp-ui.js
34
+ ```
35
+
36
+ - 缺 `@lark-apaas/nestjs-mcp` → 当前 `@lark-apaas/fullstack-nestjs-core` 不含 MCP 层(`1.1.62` 起含)。告知用户升级 core,停止;不装 `@modelcontextprotocol/sdk`、不手写端点。
37
+ - 缺 `mcp-ui.js` → 只能做工具、不能做界面(`@lark-apaas/coding-preset-vite-react` `1.0.23` 起含)。要做界面先让用户升级该预设。Rspack 预设不支持 MCP Apps。
38
+
39
+ ## 任务路由
40
+
41
+ | 任务 | 读 |
42
+ |---|---|
43
+ | 新增 / 修改工具、开通 MCP、维护应用 Skill | `references/tool-authoring.md` |
44
+ | 给工具结果加界面、改界面、界面与工具联动 | `references/mcp-apps.md` |
45
+ | 用 `mcp_call` 核对与调用、按错误定位、本地模式调试 | `references/debugging.md` |
46
+
47
+ 改完必跑 `npx miaoda-mcp validate`,再按 `references/debugging.md` 的回路用 `mcp_call` 验证;三类任务共用。`mcp_call` 报「当前运行环境不支持」是本地 CLI 宿主,走该文件的本地模式。向用户汇报用业务表述(「找到 4 个演示航班」),不贴 JSON。
48
+
49
+ ## 不变量
50
+
51
+ 1. schema 用 zod,提成常量放类上方;方法签名固定 `(input: Infer<typeof In>, ctx: McpContext)`,返回 `McpToolResult<typeof Out>`。签名写错在 `npm run type:check:server` 暴露。
52
+ 2. `description` 面向模型:用途、何时调用、参数来源、返回什么、失败怎样。每个 zod 字段 `.describe()`。消费方 Agent 只靠这些选工具、填参数。
53
+ 3. 四项 `annotations` 显式写出。只读工具必须 `readOnlyHint: true`:缺省会被当成可能写数据,调试时每次都要 `safe: true`,消费方也会额外确认。
54
+ 4. 声明了 `outputSchema` 就返回匹配的 `structuredContent`,对不上时 `mcp_call` 报「输出与其声明的 outputSchema 不符」;列表工具入参带 `limit` 与 `cursor`;写操作的幂等由 Service 保证,不引入幂等键字段。
55
+ 5. 业务失败抛 `McpToolError(message, { code })`,Agent 原样收到;其他异常只落日志、返回通用提示,Agent 看不到原因。
56
+ 6. 身份只从 `ctx.user` 读;权限与行级过滤留在 Service。工具类作为业务模块 provider 注册,单例。
57
+ 7. 工具名 = `prefix + 方法名`,匹配 `^[A-Za-z0-9_.-]{1,128}$`,全应用唯一;用 `域_动作`。
58
+ 8. 界面源码只放 `server/mcp/ui/<entry>/`,类型检查走 `server/mcp/ui/tsconfig.json`,`tsconfig.node.json` 的 `exclude` 加 `server/mcp/ui`。用 `@McpUiResource` + `readMcpUiTemplate('<entry>')` 下发,工具用 `ui: { resourceUri }` 关联。
59
+ 9. 新增、改名、删除工具后同步改对应的 `server/mcp/skills/<name>/SKILL.md`;frontmatter 的 `name` 必须等于目录名。
60
+
61
+ ## 禁止
62
+
63
+ - 从入参接收用户 ID、token、连接串——身份来自网关,入参里的身份可伪造。
64
+ - 把 `requireUser` 设为 `false`,或自己拼一个 `x-larkgw-suda-webuser` 头来让调用通过——SDK 会把它当真实用户,写工具会以这个身份落库、发通知。
65
+ - 手改 `.spark/mcp/manifest.json`——`validate --check` 会判不一致。
66
+ - 服务端 import `server/mcp/ui/` 里的源码,或界面 import `client/`、`server/` 里的业务代码、工具类、Node 模块——子构建的边界检查直接报错,服务端只经 `readMcpUiTemplate` 读构建产物。
67
+ - 界面里存凭证、直连业务系统——沙箱 iframe 是独立 origin,用户登录态带不进来;数据走宿主转发的工具调用。
68
+ - `McpToolError.data` 与 `structuredContent` 里放密钥、内部连接信息——两者原样进 Agent 上下文。
69
+ - 自己实现 MCP 协议、JSON-RPC、`/mcp` 路由、凭证校验——SDK 与网关已做,重复实现会与网关映射冲突。
70
+
71
+ ## 交付前核对
72
+
73
+ 1. `validate` 通过;`list_tools` 与源码里的工具、资源一一对应。
74
+ 2. 每个工具有面向模型的 `description`、四项 `annotations`;写工具有 `outputSchema`。
75
+ 3. 每个 `ui.resourceUri` 能 `read_resource` 读到,界面拿到 `structuredContent` 后能渲染;`npx tsc --noEmit -p server/mcp/ui/tsconfig.json` 通过。
76
+ 4. `server/mcp/skills/` 下每个业务流程一份 SKILL.md,合起来覆盖全部工具:何时调用、如何选工具、调用顺序、约束、失败处理、界面 URI;目录接口的 `prompts[]` 与目录一一对应。
77
+ 5. `.spark/mcp/manifest.json` 随代码提交。
@@ -0,0 +1,81 @@
1
+ # 调试:mcp_call、错误定位、本地模式
2
+
3
+ 本文说的「沙箱」= 工具清单里有 `mcp_call` 的环境(妙搭云端 code-agent);「本地模式」= 没有 `mcp_call`、在开发者本机跑 `npm run dev` 的环境。先看自己有哪个,再读对应部分。
4
+
5
+ ## mcp_call
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`(超 12k 字符截断)、`isError`、耗时。脱敏后的完整结果超过 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_call` 都能 curl。应用 Skill 是 Prompt 不是资源,`mcp_call` 的三个动作都碰不到它,核对它只走这里。
21
+
22
+ ## 回路
23
+
24
+ 1. 改工具 / 资源 / 界面。
25
+ 2. `npx miaoda-mcp validate`。失败即改,不进下一步。
26
+ 3. 等 dev server 重启(`nest --watch` 会自动重启;界面改动由 `[mcp-ui]` 插件自动重建)。
27
+ 4. `list_tools` 核对清单与源码一致(`visibility: ['app']` 的工具也在清单里,属正常)。
28
+ 5. `call` 逐个只读工具跑一次真实入参。写工具只在能自己清掉痕迹(有对应删除 / 撤销工具,或业务上可撤回)时才真调并随手清理;否则只验证入参校验与 `McpToolError` 分支,汇报里写明「写入链路未真实验证」。
29
+ 6. 带界面的工具 `read_resource`。
30
+ 7. 每个 `McpToolError` 分支构造一次触发(不存在的 ID、非法状态),确认 `isError: true` 且 `code` 与 `description` 预告一致。
31
+ 8. 更新 `server/mcp/skills/<name>/SKILL.md`,`validate` 通过(frontmatter 不合法在这一步就失败),再 curl 目录接口:`skills[]` 有该 `name`、`content` 是最新正文,`prompts[]` 的 `description` 一致。
32
+
33
+ ## 错误分流
34
+
35
+ | 现象 | 原因 | 动作 |
36
+ |---|---|---|
37
+ | 端点 404「当前工程可能尚未接入 MCP 层,或 dev server 尚未就绪」 | `server/mcp` 不存在;dev server 没起来;零工具零资源无应用 Skill(SDK 此时返回 404) | 确认三者;有工具但 404 看启动日志是否被校验阻断 |
38
+ | `list_tools` 少了某个工具 | 类没进任何模块 `providers`,无任何日志提示 | 对照启动日志「已注册 N 个 MCP 工具:…」,缺的加进所属业务模块的 `providers` |
39
+ | dev server 起不来,报「MCP 工具必须使用单例及单例依赖」 | 工具类或其依赖是 request / transient 作用域 | 改回单例,身份从 `ctx.user` 取 |
40
+ | 启动日志「MCP 定义校验失败」 | 工具名不合法 / 重名、资源 URI 不以 `ui://` 开头 / 重名、`ui.resourceUri` 找不到 `@McpUiResource()`、同一方法同时标两个装饰器 | 按日志逐条修,`validate` 能提前查出 |
41
+ | `tool_not_found` | 名字没带 prefix,或用了 `title` | 用 `list_tools` 里的 name |
42
+ | `requires_safe` | 见上文 `safe` | 只读工具补注解;写工具按回路第 5 步——能清掉痕迹才 `safe: true`,否则只验错误分支 |
43
+ | `isError` + `code: MCP_USER_REQUIRED`(`prompts/get` / `resources/read` 则是 JSON-RPC error) | 请求没带用户身份:有 `mcp_call` 的环境里 curl 直连 dev server / NestJS 端口就是这样 | 无身份的 curl 只做 `tools/list`、`prompts/list`、`resources/list` 与目录接口;`tools/call` / `resources/read` 走 `mcp_call`,Prompt 正文看目录接口的 `skills[].content`。不改 `requireUser`、不拼身份头 |
44
+ | `isError` + 业务 `code` | 工具按设计抛了 `McpToolError` | 对照 `description` 检查是否预告了该 code;入参是否真的该触发 |
45
+ | `isError` + 通用失败提示 | 方法抛了非 `McpToolError` 的异常 | 读结果尾部 Trace-ID 日志定位;该异常若是可预期的业务失败,改抛 `McpToolError` |
46
+ | zod 校验错误 | 入参不符 inputSchema | 对照 `list_tools` 里的 schema 改 `arguments`;schema 本身不合理就改源码 |
47
+ | 「工具已执行,但输出与其声明的 outputSchema 不符」 | `structuredContent` 与 `outputSchema` 对不上 | 工具已跑通:改返回值或改 schema,不重试同一调用 |
48
+ | `timeout` | 单个子请求 60s 无动静,或整次超 600s | 收窄入参 / 分页 / 拆成「发起 + 查状态」两个工具;加进度通知无效(JSON 模式发不出去) |
49
+ | `connect_failed` / `http_error` / `unauthorized` | 网关或预览环境问题;`http_error` 500 先对下面 frontmatter 那行,应用自己也会 500 | 看 statusCode;不是应用代码问题时告知用户,不改代码绕 |
50
+ | 「已连续 4 次调用失败,已暂停对它的重复调用」 | 同一工具连续失败 4 次熔断 | 先修实现或入参,60s 后再验 |
51
+ | 「当前运行环境不支持开发态 MCP 调试」 | 本地 CLI 宿主没有 `mcp_call` 后端 | 走下节本地模式 |
52
+ | `read_resource` 报「读取 MCP Apps 界面产物失败」 | `dist/mcp-ui/<entry>.html` 不存在 | 入口名对齐 `readMcpUiTemplate('<entry>')`;确认 `server/mcp/ui/<entry>/index.html` 存在;看 `[mcp-ui]` 日志是否构建失败 |
53
+ | dev server 日志 `[mcp-ui] 构建失败:MCP UI 不能引用应用或服务端代码 / Node.js 模块` | 界面 import 了 `client/`、`server/` 的业务代码或 Node 内置模块;HTML `src`、CSS `url()` 指到界面目录外也算 | 把依赖挪进 `server/mcp/ui/` 或 `shared/`;不改预设配置 |
54
+ | dev server(`nest start --watch`)日志或 `npm run type:check:server` 在 `server/mcp/ui/**` 报 JSX / DOM 类型错误 | `tsconfig.node.json` 的 `exclude` 没加 `server/mcp/ui`,`nest` 与 type:check 共用这份配置 | 加上;界面用 `npx tsc --noEmit -p server/mcp/ui/tsconfig.json` 单独检查 |
55
+ | `mcp_call` 任一动作 `http_error` 500、目录接口 500,启动日志有「写入 MCP 清单失败:MCP Skill …」 | 某份 `server/mcp/skills/*/SKILL.md` frontmatter 不合法:缺 frontmatter、`name` 与目录名不等、`description` 空、重名、超限 | 按日志文案修 frontmatter;`validate` 会报同一条错 |
56
+
57
+ 同一失败不原样重试。改完代码再验,每轮只改一个变量。
58
+
59
+ ## 本地模式(工具清单里没有 `mcp_call` 时才适用)
60
+
61
+ 本地开发(`MIAODA_LOCAL_DEV=1`,`npm run dev` 走 `dev-local.js`)没有 `mcp_call`,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,205 @@
1
+ # MCP Apps:给工具结果加界面
2
+
3
+ 一个 MCP App = 一个 `ui://` 资源(单文件 HTML)+ 至少一个用 `ui.resourceUri` 指向它的工具。宿主调用工具后把 HTML 放进沙箱 iframe 渲染,再把工具入参与结果通过通知推给界面;界面里的操作经宿主回调 server 的工具。
4
+
5
+ ## 先决定要不要界面
6
+
7
+ | 信号 | 做法 |
8
+ |---|---|
9
+ | 结果是列表 / 详情 / 状态,文本 + `structuredContent` 已够 Agent 继续推理 | 不加界面 |
10
+ | 用户要从多条候选里挑一个(航班、订单、文件) | 选择型界面,选中后 `updateModelContext` 同步给模型 |
11
+ | 结果是图表、地图、对比等空间信息 | 展示型界面 |
12
+ | 写操作前要让用户在界面里核对、确认 | 确认型界面,确认动作经 `callServerTool` 调一个 `visibility: ['app']` 的工具 |
13
+
14
+ 不需要的地方不加:文本更快,也没有构建与 CSP 负担。
15
+
16
+ ## 目录与构建
17
+
18
+ ```text
19
+ server/mcp/ui/
20
+ ├── tsconfig.json # 界面独立的 TS 配置,整个 ui/ 共用一份,见下
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 全白,而 `validate` 与 `read_resource` 都不会报错。
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
+ - 类型检查单独走:`server/mcp/ui/` 既不在 `tsconfig.app.json`(只含 `client/`、`shared/`)里,也不能进 `tsconfig.node.json`(服务端配置编译 JSX 报错)。首次开通界面做两件事:`tsconfig.node.json` 的 `exclude` 追加 `"server/mcp/ui"`;生成下面这份 `server/mcp/ui/tsconfig.json`。之后每次改界面跑 `npx tsc --noEmit -p server/mcp/ui/tsconfig.json`,`npm run type:check` 不覆盖它。
41
+
42
+ ```json
43
+ {
44
+ "compilerOptions": {
45
+ "target": "ES2022",
46
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
47
+ "module": "ESNext",
48
+ "moduleResolution": "Bundler",
49
+ "jsx": "react-jsx",
50
+ "strict": true,
51
+ "skipLibCheck": true,
52
+ "noEmit": true,
53
+ "esModuleInterop": true,
54
+ "baseUrl": "../../..",
55
+ "paths": { "@shared/*": ["shared/*"] }
56
+ },
57
+ "include": ["./**/*.ts", "./**/*.tsx"]
58
+ }
59
+ ```
60
+
61
+ - `@modelcontextprotocol/ext-apps` 随 SDK 安装,直接 import。解析失败时把它加进 `package.json` `dependencies`,版本与 `node_modules/@lark-apaas/nestjs-mcp/package.json` 里声明的一致(1.x),不装 2.x。
62
+
63
+ ## server 侧:声明资源并关联工具
64
+
65
+ ```typescript
66
+ import { McpTools, McpTool, McpUiResource, readMcpUiTemplate } from '@lark-apaas/fullstack-nestjs-core';
67
+
68
+ @McpTools({ prefix: 'order_' })
69
+ export class OrderMcpTools {
70
+ @McpTool({
71
+ title: '查询订单',
72
+ description: '…',
73
+ inputSchema: SearchOrdersInput,
74
+ outputSchema: SearchOrdersOutput,
75
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
76
+ ui: { resourceUri: 'ui://order/list' }, // 结果由该界面渲染;模型与界面都可调
77
+ })
78
+ async search(/* … */) { /* … */ }
79
+
80
+ @McpTool({
81
+ description: '按 ID 取一条订单明细,供订单列表界面展开详情时调用。',
82
+ inputSchema: GetOrderInput,
83
+ outputSchema: GetOrderOutput,
84
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
85
+ ui: { resourceUri: 'ui://order/list', visibility: ['app'] }, // 只供界面回调,模型工具清单里隐藏
86
+ })
87
+ async detail(/* … */) { /* … */ }
88
+
89
+ @McpUiResource({
90
+ uri: 'ui://order/list',
91
+ title: '订单列表',
92
+ description: '分页订单列表,可展开详情、勾选后取消。',
93
+ csp: { connectDomains: [], resourceDomains: [] }, // 界面要访问的外部域名写这里,默认全禁
94
+ })
95
+ listView() {
96
+ return readMcpUiTemplate('order-list'); // 读 dist/mcp-ui/order-list.html
97
+ }
98
+ }
99
+ ```
100
+
101
+ - `uri` 必须以 `ui://` 开头,全应用唯一;`ui.resourceUri` 找不到对应 `@McpUiResource()` 时 `validate` 与启动都会报错。
102
+ - `visibility` 默认 `['model', 'app']`。只给界面用的取数 / 刷新工具写 `['app']`,避免污染模型的工具清单。隐藏由宿主做,server 不过滤:`mcp_call` 的 `list_tools` 仍会列出它,`_meta.ui.visibility` 为 `['app']` 即正确,不要因为「还在清单里」去改代码。
103
+ - 一个工具既返回 `structuredContent` 又绑界面是常态;模型还需要纯数据继续推理时,拆成「取数工具(无 ui)」+「展示工具(绑 ui)」。
104
+ - 资源读取沿用 `requireUser`:无身份的 `resources/read` 会被拒。
105
+
106
+ ## 界面侧:接结果、回调工具
107
+
108
+ React 写法(`@modelcontextprotocol/ext-apps/react`):
109
+
110
+ ```tsx
111
+ // server/mcp/ui/order-list/main.tsx
112
+ import { useState } from 'react';
113
+ import { createRoot } from 'react-dom/client';
114
+ import { useApp, useHostStyles } from '@modelcontextprotocol/ext-apps/react';
115
+ // 与 outputSchema 对齐的 TS 类型放 shared/(如 shared/mcp-types.ts),server 与界面共用;@shared 是子构建唯一可用别名
116
+ import type { OrderPage as Page } from '@shared/mcp-types';
117
+
118
+ function OrderList() {
119
+ const [page, setPage] = useState<Page | null>(null);
120
+ const [error, setError] = useState<string | null>(null);
121
+
122
+ const { app, isConnected } = useApp({
123
+ appInfo: { name: 'order-list', version: '1.0.0' },
124
+ capabilities: {},
125
+ onAppCreated: (app) => {
126
+ // 宿主推送:工具入参(可先渲染骨架)、工具结果、取消
127
+ app.ontoolinput = ({ arguments: args }) => console.debug('input', args);
128
+ app.ontoolresult = (result) => {
129
+ if (result.isError) setError(textOf(result));
130
+ else setPage(result.structuredContent as Page);
131
+ };
132
+ app.ontoolcancelled = () => setError('调用已取消');
133
+ },
134
+ });
135
+ useHostStyles(app, app?.getHostContext()); // 应用宿主主题、CSS 变量与字体;第二参数让首帧就生效
136
+
137
+ async function cancel(orderId: string) {
138
+ if (!app) return;
139
+ const res = await app.callServerTool({ name: 'order_cancel', arguments: { orderId } });
140
+ if (res.isError) return setError(textOf(res));
141
+ // 把用户在界面里做的选择同步给模型,模型下一轮据此继续
142
+ await app.updateModelContext({
143
+ content: [{ type: 'text', text: `用户已在界面中取消订单 ${orderId}` }],
144
+ structuredContent: { cancelledOrderId: orderId },
145
+ });
146
+ setPage((p) => p && { ...p, items: p.items.filter((i) => i.id !== orderId) });
147
+ }
148
+
149
+ if (!isConnected) return <p>连接宿主中…</p>;
150
+ if (error) return <p role="alert">{error}</p>;
151
+ if (!page) return <p>等待查询结果…</p>;
152
+ return (
153
+ <ul>
154
+ {page.items.map((o) => (
155
+ <li key={o.id}>
156
+ {o.customer} · {o.amount} 元 · {o.status}
157
+ {o.status === 'pending' && <button onClick={() => cancel(o.id)}>取消</button>}
158
+ </li>
159
+ ))}
160
+ </ul>
161
+ );
162
+ }
163
+
164
+ function textOf(result: { content?: Array<{ type: string; text?: string }> }) {
165
+ return result.content?.find((c) => c.type === 'text')?.text ?? '调用失败';
166
+ }
167
+
168
+ createRoot(document.getElementById('root')!).render(<OrderList />);
169
+ ```
170
+
171
+ 原生 JS 写法(`@modelcontextprotocol/ext-apps`):
172
+
173
+ ```typescript
174
+ import { App } from '@modelcontextprotocol/ext-apps';
175
+ const app = new App({ name: 'order-list', version: '1.0.0' }, {});
176
+ app.ontoolresult = (result) => render(result.structuredContent);
177
+ await app.connect();
178
+ ```
179
+
180
+ 宿主能力速查(`App` 实例方法):
181
+
182
+ | 方法 | 用途 |
183
+ |---|---|
184
+ | `ontoolinput` / `ontoolinputpartial` / `ontoolresult` / `ontoolcancelled` | 宿主推送的入参(完整 / 流式)、结果、取消 |
185
+ | `callServerTool({ name, arguments })` | 经宿主调本 server 的工具;工具名带 prefix;返回标准 `CallToolResult` |
186
+ | `updateModelContext({ content?, structuredContent? })` | 把界面状态同步给模型,不触发模型立刻回复 |
187
+ | `sendMessage({ role: 'user', content })` | 以用户身份发一条消息,触发模型回复 |
188
+ | `getHostContext()` / `onhostcontextchanged` | 主题、`displayMode`、`containerDimensions`、`locale`、`timeZone`、发起本次渲染的 `toolInfo` |
189
+ | `requestDisplayMode({ mode })` | 请求 inline / fullscreen 等展示模式,以 `availableDisplayModes` 为限 |
190
+
191
+ `useApp` 默认开自动调高(`autoResize`),内容变化不用手动发尺寸。
192
+
193
+ ## 界面约束
194
+
195
+ - 不存凭证、不读 cookie / localStorage 里的登录态:iframe 是独立 origin,用户在业务系统的登录态带不进来。所有数据走 `callServerTool`,身份由宿主转发。
196
+ - 外部域名写进 `@McpUiResource` 的 `csp`:`connectDomains`(fetch / WebSocket)、`resourceDomains`(图片、脚本、样式)、`frameDomains`(内嵌 iframe)、`baseUriDomains`;默认全禁,漏写表现为资源静默加载失败。
197
+ - 业务返回值用 `textContent` 或 JSX 文本渲染,不拼 `innerHTML`。
198
+ - 每次调用重新注入数据;界面不假设自己能跨调用保留状态。
199
+ - 宿主不支持 MCP Apps 时只显示 `content` 文本,所以带界面的工具也要返回可读的文本摘要。
200
+
201
+ ## 调试
202
+
203
+ - `mcp_call` `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,不改预设。
204
+ - 界面逻辑用 `mcp_call` 看不到;先用 `call` 确认 `structuredContent` 形状正确,再在预览里看渲染。宿主渲染失败时保留工具结果,只重试界面。
205
+ - 界面里调 `callServerTool` 失败,错误也走 server 日志:按 Trace-ID 查。
@@ -0,0 +1,206 @@
1
+ # 工具类写法
2
+
3
+ API 形状以 `node_modules/@lark-apaas/nestjs-mcp/dist/index.d.ts` 为准。下面是当前版本的写法与约束;字段名对不上时以 d.ts 为准。
4
+
5
+ ## 骨架
6
+
7
+ ```typescript
8
+ // server/mcp/tools/order.tools.ts
9
+ import { z } from 'zod';
10
+ import {
11
+ McpTools,
12
+ McpTool,
13
+ McpToolError,
14
+ type Infer,
15
+ type McpContext,
16
+ type McpToolResult,
17
+ } from '@lark-apaas/fullstack-nestjs-core';
18
+ import { OrderService } from '@server/modules/order/order.service';
19
+
20
+ // schema 提成常量:方法签名用 Infer<typeof …> 引用,清单导出也从这里取
21
+ export const SearchOrdersInput = {
22
+ keyword: z.string().optional().describe('客户名关键字,模糊匹配'),
23
+ limit: z.number().int().min(1).max(50).default(20).describe('单页条数'),
24
+ cursor: z.string().optional().describe('上一页返回的 nextCursor;首页不传'),
25
+ };
26
+ export const SearchOrdersOutput = {
27
+ items: z.array(
28
+ z.object({
29
+ id: z.string(),
30
+ customer: z.string(),
31
+ amount: z.number().describe('金额,单位元'),
32
+ status: z.enum(['pending', 'paid', 'cancelled']),
33
+ }),
34
+ ),
35
+ nextCursor: z.string().nullable().describe('还有下一页时非空,原样传回 cursor'),
36
+ };
37
+
38
+ export const CancelOrderInput = { orderId: z.string().describe('订单 ID,来自 order_search 的 items[].id') };
39
+ export const CancelOrderOutput = { orderId: z.string(), status: z.literal('cancelled') };
40
+
41
+ @McpTools({ prefix: 'order_' })
42
+ export class OrderMcpTools {
43
+ constructor(private readonly orders: OrderService) {}
44
+
45
+ @McpTool({
46
+ title: '查询订单',
47
+ description:
48
+ '按客户名关键字分页查询当前用户可见的订单。用户想找订单、看订单列表时调用;一页最多 50 条,超出用 cursor 翻页。',
49
+ inputSchema: SearchOrdersInput,
50
+ outputSchema: SearchOrdersOutput,
51
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
52
+ })
53
+ async search(
54
+ input: Infer<typeof SearchOrdersInput>,
55
+ ctx: McpContext,
56
+ ): Promise<McpToolResult<typeof SearchOrdersOutput>> {
57
+ const userId = ctx.user.userId!; // requireUser 默认开启,执行到这里必有值;取一次往下传
58
+ const page = await this.orders.search({ ...input, userId });
59
+ return {
60
+ structuredContent: page,
61
+ content: [{ type: 'text', text: `找到 ${page.items.length} 条订单` }],
62
+ };
63
+ }
64
+
65
+ @McpTool({
66
+ title: '取消订单',
67
+ description:
68
+ '取消一个待支付订单。仅在用户明确要求取消、且订单状态为 pending 时调用;订单不存在返回 ORDER_NOT_FOUND,状态不允许返回 ORDER_NOT_CANCELLABLE。重复取消同一订单返回同一结果。',
69
+ inputSchema: CancelOrderInput,
70
+ outputSchema: CancelOrderOutput,
71
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
72
+ })
73
+ async cancel(
74
+ input: Infer<typeof CancelOrderInput>,
75
+ ctx: McpContext,
76
+ ): Promise<McpToolResult<typeof CancelOrderOutput>> {
77
+ const userId = ctx.user.userId!;
78
+ const order = await this.orders.findVisibleTo(input.orderId, userId);
79
+ if (!order) throw new McpToolError('订单不存在', { code: 'ORDER_NOT_FOUND' });
80
+ if (order.status === 'cancelled') return { structuredContent: { orderId: order.id, status: 'cancelled' } };
81
+ if (order.status !== 'pending') {
82
+ throw new McpToolError(`订单状态为 ${order.status},不能取消`, { code: 'ORDER_NOT_CANCELLABLE' });
83
+ }
84
+ await this.orders.cancel(order.id, userId);
85
+ return { structuredContent: { orderId: order.id, status: 'cancelled' } };
86
+ }
87
+ }
88
+ ```
89
+
90
+ ```typescript
91
+ // server/modules/order/order.module.ts —— 工具类和它依赖的 Service 注册在同一个模块
92
+ import { Module } from '@nestjs/common';
93
+ // OrderController / OrderService / OrderMcpTools 的 import 略
94
+
95
+ @Module({
96
+ controllers: [OrderController],
97
+ providers: [OrderService, OrderMcpTools],
98
+ })
99
+ export class OrderModule {}
100
+ ```
101
+
102
+ `@McpTools()` 自带 `@Injectable()`。SDK 启动时用 DiscoveryService 扫所有 provider;类没进任何模块的 `providers` 就扫不到它,启动日志不会有任何提示,工具静默缺席——启动日志「已注册 N 个 MCP 工具:…」列出的名字才是真相,对不上就是没注册。工具类及其依赖必须是单例:request / transient 作用域会在启动时抛「MCP 工具必须使用单例及单例依赖」,整个应用起不来。
103
+
104
+ ## 装饰器选项
105
+
106
+ `@McpTool(options)`:
107
+
108
+ | 字段 | 说明 |
109
+ |---|---|
110
+ | `name?` | 默认取方法名;最终工具名 = `prefix + name`,需匹配 `^[A-Za-z0-9_.-]{1,128}$`,全应用唯一 |
111
+ | `title?` | 面向人的展示名,面板里显示 |
112
+ | `description` | 必填,面向模型 |
113
+ | `inputSchema?` / `outputSchema?` | zod:原始形状 `{ a: z.string() }` 或 `z.object({...})` 都行;顶层必须是对象 |
114
+ | `annotations?` | `readOnlyHint` / `destructiveHint` / `idempotentHint` / `openWorldHint`,四项都写 |
115
+ | `ui?` | `{ resourceUri: 'ui://…', visibility?: ['model' \| 'app'] }`,见 `mcp-apps.md` |
116
+ | `meta?` | 额外 `_meta`,与 `ui` 生成的字段合并 |
117
+
118
+ `@McpTools({ prefix? })`:给类下所有工具名加前缀,如 `order_`。
119
+
120
+ ## 方法签名与返回值
121
+
122
+ - 固定 `(input, ctx)`。无入参的工具 `input` 为 `Record<string, never>`,仍要写第二个参数 `ctx`。
123
+ - 声明了 `outputSchema`:返回 `{ structuredContent }`,类型由 schema 推导;`content` 可选,SDK 会自动补一份 JSON 文本给不支持结构化输出的客户端。加一句业务摘要的 `content` 让文本客户端也能读。
124
+ - 未声明 `outputSchema`:返回 `{ content: [{ type: 'text', text }] }`;`content` 还支持 `image` / `audio` / `resource_link` 块。
125
+ - `isError: true` 由 SDK 在捕获 `McpToolError` 时设置,不需要手动返回。
126
+
127
+ ## McpContext
128
+
129
+ | 字段 | 用途 |
130
+ |---|---|
131
+ | `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 签名 |
132
+ | `request` | 原始 HTTP 请求,读 header、logid |
133
+ | `signal` | 客户端取消时触发,长任务把它传给下游 |
134
+ | `requestId` | MCP 请求 id |
135
+ | `extra` | SDK 的 `RequestHandlerExtra`(`sessionId` / `requestId` / `authInfo` 等)。它有 `sendNotification`,但端点是无状态 JSON 模式,`notifications/progress` 发不到客户端——长任务别靠进度通知续命,拆成「发起 + 查状态」两个工具 |
136
+
137
+ ## 错误处理
138
+
139
+ - 可预期的业务失败抛 `new McpToolError(message, { code?, data? })`:SDK 转成 `isError: true`,`message` / `code` / `data` 原样进 Agent 上下文。`code` 用 `SCREAMING_SNAKE`,并在 `description` 里预告,Agent 才知道怎么应对。
140
+ - 其他异常:SDK 记日志、返回通用失败提示,Agent 看不到原因。调试时看到「通用失败提示」就去读 Trace-ID 日志。
141
+ - 入参校验交给 zod,方法体不重复判空;zod 失败由 SDK 返回,方法不执行。
142
+
143
+ ## description 怎么写
144
+
145
+ 消费方 Agent 只靠 `description` 与字段 `.describe()` 决定调不调、怎么填。写全五件事:用途、何时调用(含前置条件)、参数从哪来(「来自 order_search 的 items[].id」)、返回什么、失败会返回哪些 code。写操作再加一句幂等语义(「重复取消返回同一结果」)。
146
+
147
+ 字段描述写单位、格式、取值来源;枚举用 `z.enum`;入参不用 `z.any()` / `z.unknown()`。
148
+
149
+ ## 开通(首次)
150
+
151
+ 1. 前置检查通过(见 SKILL.md)。
152
+ 2. 建 `server/mcp/tools/<域>.tools.ts`,至少一个只读工具复用现有 Service。
153
+ 3. 把工具类加进所属业务模块的 `providers`。
154
+ 4. 写 `server/mcp/skills/<name>/SKILL.md`(下节)。
155
+ 5. `npx miaoda-mcp validate` 通过,`.spark/mcp/manifest.json` 一起提交。
156
+ 6. 进 `debugging.md` 的调试回路。
157
+
158
+ ## 应用 Skill:`server/mcp/skills/<name>/SKILL.md`
159
+
160
+ 给消费方 Agent 的使用说明。一个业务流程一个目录,目录里只认固定文件名 `SKILL.md`。SDK 把每份注册为同名 MCP Prompt:`prompts/list` 给 `name` / `description`,`prompts/get` 返回去掉 frontmatter 的正文(一条 `role: user` 文本消息)。它不是 Resource、没有 `skill://` URI,`resources/list` 里看不到它是正常的。每次请求重新读文件,改完不用重启。
161
+
162
+ frontmatter 规则。SDK 每次请求、`validate` 都会校验,不合法时整个 MCP 请求 500、`validate` 失败,不会静默跳过这一份:
163
+
164
+ - `name` 必填且等于目录名:1–64 位小写字母、数字与单个连字符,不能首尾连字符、不能连续 `--`。
165
+ - `description` 必填非空,不超过 1024 字符;消费方只靠它决定要不要取这份 Prompt,写清适用场景。
166
+ - 全应用不重名;最多 100 份,单文件不超过 1 MiB,合计不超过 8 MiB;必须是工程内普通文件,不接受符号链接。
167
+ - 只有 `SKILL.md` 会被读取和交付,同目录的图片、附件不会。
168
+
169
+ 正文必含:适用场景、实际工具名与关键参数、调用顺序、需要用户确认的边界、身份与权限要求、每个错误 code 的处理、界面 URI(如有)。只写已实现的能力;不写凭证、用户数据、内部实现。
170
+
171
+ 示例 `server/mcp/skills/orders/SKILL.md`。`---` 必须是文件第一行,前面不能有任何字符,注释也不行:
172
+
173
+ ```markdown
174
+ ---
175
+ name: orders
176
+ description: 查询当前用户可见的订单,并在用户明确要求后取消待支付订单。
177
+ ---
178
+ # 订单能力
179
+ - 需要已登录用户身份;只返回当前用户可见的订单。
180
+ - 找订单:order_search,keyword 模糊匹配客户名;nextCursor 非空时用 cursor 翻页。
181
+ - 取消订单:只有用户明确要求取消、且 status 为 pending 时调用 order_cancel;orderId 取自 order_search 的 items[].id。
182
+ - ORDER_NOT_FOUND:请用户核对订单 ID。ORDER_NOT_CANCELLABLE:告知当前状态,不重试。
183
+ - 支持界面的宿主会用 ui://order/list 展示查询结果。
184
+ ```
185
+
186
+ ## 校验与清单
187
+
188
+ - `npx miaoda-mcp validate`:用工程自身 TypeScript 加载 `server/mcp` 下的工具源码(不启动应用,跳过 `ui/` 与 `skills/`),检查工具名格式、重名、`ui://` 前缀、`ui.resourceUri` 引用、每份应用 Skill 的 frontmatter,写入 `.spark/mcp/manifest.json`(`tools` / `resources` / `prompts` / `skills` 与源码定位)。
189
+ - `npx miaoda-mcp validate --check`:只比对不写入,CI 用。
190
+ - 开发态启动应用也会写清单;生产不写。删光工具、资源与应用 Skill 后再跑一次 `validate` 清理旧清单。
191
+ - 启动期同样的校验失败会阻断启动(「MCP 定义校验失败」),一个方法不能同时标 `@McpTool()` 与 `@McpUiResource()`。
192
+
193
+ ## 模块配置(一般不动)
194
+
195
+ ```typescript
196
+ PlatformModule.forRoot({
197
+ mcp: {
198
+ serverName: 'my-app', // 默认 SUDA_APP_ID,再缺省为 miaoda-app
199
+ serverVersion: '1.0.0',
200
+ instructions: '…', // initialize 响应里的说明
201
+ },
202
+ });
203
+ // 关闭端点:PlatformModule.forRoot({ mcp: false })
204
+ ```
205
+
206
+ `requireUser` 保持默认 `true`。零工具、零资源、无应用 Skill 时端点返回 404,这是正常状态不是故障。