@lark-apaas/coding-steering 0.1.32-dev.942e73f → 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.
@@ -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,这是正常状态不是故障。