lakutata 3.0.0 → 3.1.0

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.
Files changed (117) hide show
  1. package/README.md +50 -30
  2. package/README.zh-CN.md +130 -0
  3. package/dist/cjs/exports/QueueComponent.d.ts +1 -0
  4. package/dist/cjs/exports/QueueComponent.js +20 -0
  5. package/dist/esm/exports/QueueComponent.js +1 -0
  6. package/dist/types/exports/QueueComponent.d.ts +1 -0
  7. package/doc/en/Application.md +107 -0
  8. package/doc/en/CLI.md +274 -0
  9. package/doc/en/Cache.md +379 -0
  10. package/doc/en/Command.md +110 -0
  11. package/doc/en/ContainerdIntegration.md +17 -16
  12. package/doc/en/Controllers.md +256 -0
  13. package/doc/en/DBus.md +291 -0
  14. package/doc/en/DTOValidation.md +72 -43
  15. package/doc/en/Database.md +188 -0
  16. package/doc/en/DatabaseDrivers.md +287 -0
  17. package/doc/en/Decorator.md +52 -40
  18. package/doc/en/DockerIntegration.md +7 -6
  19. package/doc/en/Entities.md +240 -0
  20. package/doc/en/Entrypoint.md +216 -0
  21. package/doc/en/Exceptions.md +174 -0
  22. package/doc/en/GRPC.md +251 -0
  23. package/doc/en/GettingStarted.md +165 -0
  24. package/doc/en/HTTP.md +507 -0
  25. package/doc/en/HelperFunctions.md +11 -11
  26. package/doc/en/I18n.md +119 -115
  27. package/doc/en/Injection.md +144 -0
  28. package/doc/en/Installers.md +280 -0
  29. package/doc/en/Logger.md +206 -0
  30. package/doc/en/MCP.md +293 -0
  31. package/doc/en/Middlewares.md +213 -0
  32. package/doc/en/Migrations.md +202 -0
  33. package/doc/en/MongoEmbedded.md +149 -0
  34. package/doc/en/Monitor.md +164 -0
  35. package/doc/en/Mutex.md +27 -25
  36. package/doc/en/NATS.md +246 -0
  37. package/doc/en/NativeAddons.md +212 -0
  38. package/doc/en/Objects.md +143 -0
  39. package/doc/en/Overview.md +62 -85
  40. package/doc/en/Packaging.md +193 -0
  41. package/doc/en/ProjectStructure.md +124 -0
  42. package/doc/en/Queue.md +269 -0
  43. package/doc/en/Schedules.md +217 -0
  44. package/doc/en/Security.md +10 -9
  45. package/doc/en/ServiceCalls.md +504 -0
  46. package/doc/en/Time.md +19 -20
  47. package/doc/en/UpgradingFrom1.md +435 -0
  48. package/doc/en/UpgradingFrom2.md +315 -0
  49. package/doc/en/Workspaces.md +189 -0
  50. package/doc/en/examples/BackgroundJobs.md +677 -0
  51. package/doc/en/examples/CommandLineTool.md +664 -0
  52. package/doc/en/examples/DesktopApp.md +809 -0
  53. package/doc/en/examples/DeviceService.md +607 -0
  54. package/doc/en/examples/MCPServer.md +653 -0
  55. package/doc/en/examples/README.md +16 -0
  56. package/doc/en/examples/RestAPI.md +490 -0
  57. package/doc/en/examples/ServiceCalls.md +829 -0
  58. package/doc/en/examples/WebApp.md +568 -0
  59. package/doc/zh/Application.md +75 -0
  60. package/doc/zh/CLI.md +250 -0
  61. package/doc/zh/Cache.md +328 -0
  62. package/doc/zh/Command.md +78 -0
  63. package/doc/zh/ContainerdIntegration.md +9 -9
  64. package/doc/zh/Controllers.md +228 -0
  65. package/doc/zh/DBus.md +257 -0
  66. package/doc/zh/DTOValidation.md +49 -19
  67. package/doc/zh/Database.md +175 -0
  68. package/doc/zh/DatabaseDrivers.md +261 -0
  69. package/doc/zh/Decorator.md +25 -18
  70. package/doc/zh/DockerIntegration.md +6 -6
  71. package/doc/zh/Entities.md +224 -0
  72. package/doc/zh/Entrypoint.md +193 -0
  73. package/doc/zh/Exceptions.md +158 -0
  74. package/doc/zh/GRPC.md +226 -0
  75. package/doc/zh/GettingStarted.md +148 -0
  76. package/doc/zh/HTTP.md +445 -0
  77. package/doc/zh/HelperFunctions.md +2 -2
  78. package/doc/zh/I18n.md +26 -26
  79. package/doc/zh/Injection.md +129 -0
  80. package/doc/zh/Installers.md +232 -0
  81. package/doc/zh/Logger.md +188 -0
  82. package/doc/zh/MCP.md +265 -0
  83. package/doc/zh/Middlewares.md +198 -0
  84. package/doc/zh/Migrations.md +178 -0
  85. package/doc/zh/MongoEmbedded.md +118 -0
  86. package/doc/zh/Monitor.md +145 -0
  87. package/doc/zh/Mutex.md +10 -10
  88. package/doc/zh/NATS.md +207 -0
  89. package/doc/zh/NativeAddons.md +180 -0
  90. package/doc/zh/Objects.md +119 -0
  91. package/doc/zh/Overview.md +46 -71
  92. package/doc/zh/Packaging.md +154 -0
  93. package/doc/zh/ProjectStructure.md +102 -0
  94. package/doc/zh/Queue.md +214 -0
  95. package/doc/zh/Schedules.md +193 -0
  96. package/doc/zh/Security.md +6 -6
  97. package/doc/zh/ServiceCalls.md +459 -0
  98. package/doc/zh/Time.md +2 -2
  99. package/doc/zh/UpgradingFrom1.md +347 -0
  100. package/doc/zh/UpgradingFrom2.md +230 -0
  101. package/doc/zh/Workspaces.md +150 -0
  102. package/doc/zh/examples/BackgroundJobs.md +618 -0
  103. package/doc/zh/examples/CommandLineTool.md +618 -0
  104. package/doc/zh/examples/DesktopApp.md +748 -0
  105. package/doc/zh/examples/DeviceService.md +546 -0
  106. package/doc/zh/examples/MCPServer.md +604 -0
  107. package/doc/zh/examples/README.md +14 -0
  108. package/doc/zh/examples/RestAPI.md +458 -0
  109. package/doc/zh/examples/ServiceCalls.md +765 -0
  110. package/doc/zh/examples/WebApp.md +520 -0
  111. package/package.json +46 -17
  112. package/doc/en/ActionPattern.md +0 -408
  113. package/doc/en/Core.md +0 -312
  114. package/doc/en/ORMIntegration.md +0 -341
  115. package/doc/zh/ActionPattern.md +0 -334
  116. package/doc/zh/Core.md +0 -256
  117. package/doc/zh/ORMIntegration.md +0 -319
@@ -0,0 +1,604 @@
1
+ # 给 AI 助手用的 MCP 服务器
2
+
3
+ 这个示例构建一个给 AI 助手用的笔记服务器:Claude Code、Claude Desktop 或其他 MCP 客户端运行它,模型就能用它保存、搜索和删除用户的笔记,笔记保存在一个 JSON 文件中。它从 `default` 模板开始,展示 MCP 服务器的各个部分:用 DTO 校验参数的工具、一个资源和一个提示词、客户端自己启动服务器时使用的 stdio 传输方式、HTTP 传输方式,以及用 SDK 的 MCP 客户端编写的测试。
4
+
5
+ | 名字 | 类型 | 作用 |
6
+ |---|---|---|
7
+ | `notes_add` | 工具 | 保存一条笔记:标题、正文和标签 |
8
+ | `notes_search` | 工具 | 按关键词和标签查找笔记,最新的在前 |
9
+ | `notes_delete` | 工具 | 删除一条笔记 |
10
+ | `note://{id}` | 资源 | 以 Markdown 格式给出一条笔记 |
11
+ | `notes_review` | 提示词 | 让模型总结带某个标签的笔记 |
12
+
13
+ ## 1. 创建项目
14
+
15
+ ```bash
16
+ npx @lakutata/cli create --name notes --template default --yes
17
+ cd notes
18
+ npm install @lakutata/mcp
19
+ npm install --save-dev @modelcontextprotocol/client
20
+ npm uninstall express @types/express
21
+ rm src/controllers/ExampleController.ts src/dtos/GreetDTO.ts
22
+ ```
23
+
24
+ MCP 客户端把服务器作为单个进程运行,所以只包含一个应用的 `default` 模板正合适(见 [ProjectStructure](../ProjectStructure.md))。`@lakutata/mcp` 通过 MCP 提供应用的动作,它不属于 `lakutata`(见 [MCP](../MCP.md))。SDK 的 MCP 客户端 `@modelcontextprotocol/client` 只用于测试。服务器没有 HTTP 路由,所以删掉了 express 和模板中的示例控制器。
25
+
26
+ ## 2. 输入和结果
27
+
28
+ **`src/dtos/NoteDTO.ts`**
29
+
30
+ ```typescript
31
+ import {DTO} from 'lakutata'
32
+ import {Expect} from 'lakutata/decorator/dto'
33
+
34
+ /**
35
+ * 一条笔记
36
+ */
37
+ export class NoteDTO extends DTO {
38
+ @Expect(DTO.Number().integer().required())
39
+ public id: number
40
+
41
+ @Expect(DTO.String().required())
42
+ public title: string
43
+
44
+ @Expect(DTO.String().required())
45
+ public text: string
46
+
47
+ @Expect(DTO.Array(DTO.String()).required())
48
+ public tags: string[]
49
+
50
+ @Expect(DTO.String().isoDate().required())
51
+ public createdAt: string
52
+ }
53
+
54
+ /**
55
+ * 按 id 指定的笔记(在工具的参数中是数字,在 URI 中是文本,由 strict(false) 转换)
56
+ */
57
+ export class NoteIdDTO extends DTO {
58
+ @Expect(DTO.Number().integer().positive().strict(false).required().description('The id of the note'))
59
+ public id: number
60
+ }
61
+
62
+ /**
63
+ * 新笔记
64
+ */
65
+ export class AddNoteDTO extends DTO {
66
+ @Expect(DTO.String().min(1).max(200).required().description('A short title'))
67
+ public title: string
68
+
69
+ @Expect(DTO.String().min(1).max(20000).required().description('The text of the note, in Markdown'))
70
+ public text: string
71
+
72
+ @Expect(DTO.Array(DTO.String().min(1).max(30)).max(10).default([]).description('Tags, such as "home" or "work"'))
73
+ public tags: string[]
74
+ }
75
+
76
+ /**
77
+ * 搜索笔记的条件
78
+ */
79
+ export class SearchNotesDTO extends DTO {
80
+ @Expect(DTO.String().allow('').default('').description('The words that the title or the text must hold; empty for every note'))
81
+ public query: string
82
+
83
+ @Expect(DTO.String().optional().description('Only the notes that have this tag'))
84
+ public tag?: string
85
+
86
+ @Expect(DTO.Number().integer().min(1).max(50).default(10).description('The most notes to return'))
87
+ public limit: number
88
+ }
89
+
90
+ /**
91
+ * 找到的笔记,最新的在前
92
+ */
93
+ export class SearchResultDTO extends DTO {
94
+ @Expect(DTO.Number().integer().required().description('The number of the notes found'))
95
+ public total: number
96
+
97
+ @Expect(DTO.Array(NoteDTO.Schema()).required().description('The notes found, up to the limit'))
98
+ public notes: NoteDTO[]
99
+ }
100
+
101
+ /**
102
+ * 带某个标签的笔记(提示词的参数总是文本)
103
+ */
104
+ export class TagDTO extends DTO {
105
+ @Expect(DTO.String().required().description('The tag of the notes'))
106
+ public tag: string
107
+ }
108
+ ```
109
+
110
+ 工具会把 DTO 的 JSON Schema 发给客户端,模型按这个 Schema 传入参数来调用工具;和所有动作一样,参数随后由 DTO 校验(见 [DTOValidation](../DTOValidation.md)):
111
+
112
+ - `.description()` 向模型说明参数的含义;默认值(`tags`、`query`、`limit`)也写在 Schema 中;
113
+ - `NoteIdDTO` 既用于工具,也用于资源:工具的参数是 JSON(数字 `1`),资源的变量是文本(`note://1` 中的 `'1'`),由 `.strict(false)` 转换;
114
+ - `NoteDTO` 和 `SearchResultDTO` 描述两个工具的结果(即它们的 `output`,见第 5 节);
115
+ - `TagDTO` 是提示词的参数;提示词的参数都是文本。
116
+
117
+ ## 3. 异常
118
+
119
+ **`src/exceptions/NoteNotFoundException.ts`**
120
+
121
+ ```typescript
122
+ import {Exception} from 'lakutata'
123
+
124
+ /**
125
+ * 笔记不存在
126
+ */
127
+ export class NoteNotFoundException extends Exception {
128
+ public errno: string = 'E_NOTE_NOT_FOUND'
129
+
130
+ public readonly statusCode: number = 404
131
+ }
132
+ ```
133
+
134
+ 异常有一个 `errno` 和一个状态码(见 [Exceptions](../Exceptions.md))。工具抛出这个异常时,模型收到一个标记为错误的结果 `E_NOTE_NOT_FOUND: The note 9 does not exist`,可以据此修正调用。读取资源时抛出这个异常,由于状态码是 404,客户端收到的是协议错误 `-32602`(参数无效)。
135
+
136
+ ## 4. 笔记
137
+
138
+ **`src/components/NoteStore.ts`**
139
+
140
+ ```typescript
141
+ import {existsSync, readFileSync, renameSync, writeFileSync} from 'node:fs'
142
+ import {Component, DTO} from 'lakutata'
143
+ import {Configurable} from 'lakutata/decorator/di'
144
+ import {type NoteDTO} from '../dtos/NoteDTO.js'
145
+ import {NoteNotFoundException} from '../exceptions/NoteNotFoundException.js'
146
+
147
+ /**
148
+ * 笔记,保存在一个 JSON 文件中
149
+ */
150
+ export class NoteStore extends Component {
151
+
152
+ /**
153
+ * 保存笔记的 JSON 文件,保存第一条笔记时创建
154
+ */
155
+ @Configurable(DTO.String().required())
156
+ protected readonly file: string
157
+
158
+ /**
159
+ * 文件中的笔记,每次调用都重新读取:多个客户端各自的服务器可能共用这个文件
160
+ */
161
+ protected read(): NoteDTO[] {
162
+ return existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : []
163
+ }
164
+
165
+ /**
166
+ * 先把笔记写入临时文件,再重命名覆盖原文件,这样文件永远不会只写了一半
167
+ * @param notes
168
+ */
169
+ protected write(notes: NoteDTO[]): void {
170
+ const temporary: string = `${this.file}.${process.pid}.tmp`
171
+ writeFileSync(temporary, JSON.stringify(notes, null, 2))
172
+ renameSync(temporary, this.file)
173
+ }
174
+
175
+ /**
176
+ * 保存一条新笔记
177
+ * @param title
178
+ * @param text
179
+ * @param tags
180
+ */
181
+ public add(title: string, text: string, tags: string[]): NoteDTO {
182
+ const notes: NoteDTO[] = this.read()
183
+ const id: number = Math.max(0, ...notes.map((note: NoteDTO): number => note.id)) + 1
184
+ const note: NoteDTO = {id: id, title: title, text: text, tags: tags, createdAt: new Date().toISOString()} as NoteDTO
185
+ this.write([...notes, note])
186
+ return note
187
+ }
188
+
189
+ /**
190
+ * 取一条笔记
191
+ * @param id
192
+ */
193
+ public get(id: number): NoteDTO {
194
+ const note: NoteDTO | undefined = this.read().find((note: NoteDTO): boolean => note.id === id)
195
+ if (!note) throw new NoteNotFoundException(`The note ${id} does not exist`)
196
+ return note
197
+ }
198
+
199
+ /**
200
+ * 带有该标签、并且标题或正文包含查询中每个词的笔记,最新的在前
201
+ * @param query
202
+ * @param tag
203
+ */
204
+ public search(query: string, tag?: string): NoteDTO[] {
205
+ const words: string[] = query.toLowerCase().split(/\s+/).filter(Boolean)
206
+ return this.read()
207
+ .filter((note: NoteDTO): boolean => tag === undefined || note.tags.includes(tag))
208
+ .filter((note: NoteDTO): boolean => words.every((word: string): boolean => `${note.title} ${note.text}`.toLowerCase().includes(word)))
209
+ .reverse()
210
+ }
211
+
212
+ /**
213
+ * 删除一条笔记
214
+ * @param id
215
+ */
216
+ public remove(id: number): NoteDTO {
217
+ const note: NoteDTO = this.get(id)
218
+ this.write(this.read().filter((other: NoteDTO): boolean => other.id !== id))
219
+ return note
220
+ }
221
+ }
222
+ ```
223
+
224
+ - `NoteStore` 是一个组件:整个应用只有一个实例,而控制器是每个请求创建一个(见 [Objects](../Objects.md))。
225
+ - `@Configurable` 把 `file` 变成组件的选项:它的值在声明组件的地方给出(第 6 节),并在应用启动时校验(见 [Injection](../Injection.md))。
226
+ - 每个客户端运行自己的服务器:如果 Claude Code 和 Claude Desktop 使用同一个文件,就会有两个进程读写它。所以每次调用都重新读取文件,而不是把笔记留在内存中;写入时先把全部笔记写到临时文件,再重命名覆盖原文件。
227
+
228
+ ## 5. 控制器
229
+
230
+ **`src/controllers/NoteController.ts`**
231
+
232
+ ```typescript
233
+ import {Controller} from 'lakutata/com/entrypoint'
234
+ import {Prefix} from 'lakutata/decorator/ctrl'
235
+ import {Inject} from 'lakutata/decorator/di'
236
+ import {MCPPrompt, MCPResource, MCPTool} from '@lakutata/mcp'
237
+ import {type NoteStore} from '../components/NoteStore.js'
238
+ import {AddNoteDTO, NoteDTO, NoteIdDTO, SearchNotesDTO, SearchResultDTO, TagDTO} from '../dtos/NoteDTO.js'
239
+
240
+ @Prefix({mcp: 'notes'})
241
+ export class NoteController extends Controller {
242
+
243
+ /**
244
+ * 笔记(选项中的组件 "notes")
245
+ */
246
+ @Inject('notes')
247
+ protected readonly notes: NoteStore
248
+
249
+ /**
250
+ * 工具 notes_add
251
+ * @param input
252
+ */
253
+ @MCPTool('add', AddNoteDTO, {
254
+ description: 'Save a note. Use it when the user asks you to remember something.',
255
+ output: NoteDTO
256
+ })
257
+ public async add(input: AddNoteDTO): Promise<NoteDTO> {
258
+ return this.notes.add(input.title, input.text, input.tags)
259
+ }
260
+
261
+ /**
262
+ * 工具 notes_search
263
+ * @param input
264
+ */
265
+ @MCPTool('search', SearchNotesDTO, {
266
+ description: 'Search the notes of the user by words and by tag. Use it before answering a question that the notes may answer.',
267
+ readOnly: true,
268
+ output: SearchResultDTO
269
+ })
270
+ public async search(input: SearchNotesDTO): Promise<SearchResultDTO> {
271
+ const found: NoteDTO[] = this.notes.search(input.query, input.tag)
272
+ return {total: found.length, notes: found.slice(0, input.limit)} as SearchResultDTO
273
+ }
274
+
275
+ /**
276
+ * 工具 notes_delete
277
+ * @param input
278
+ */
279
+ @MCPTool('delete', NoteIdDTO, {description: 'Delete a note', destructive: true})
280
+ public async delete(input: NoteIdDTO): Promise<string> {
281
+ const note: NoteDTO = this.notes.remove(input.id)
282
+ return `The note ${note.id} (${note.title}) is deleted`
283
+ }
284
+
285
+ /**
286
+ * 资源 note://1:一条 Markdown 格式的笔记
287
+ * @param input
288
+ */
289
+ @MCPResource('note://{id}', NoteIdDTO, {resourceName: 'note', mimeType: 'text/markdown', description: 'A note, in Markdown'})
290
+ public async note(input: NoteIdDTO): Promise<string> {
291
+ const note: NoteDTO = this.notes.get(input.id)
292
+ return `# ${note.title}\n\n${note.text}\n\nTags: ${note.tags.join(', ') || 'none'}\n`
293
+ }
294
+
295
+ /**
296
+ * 提示词 notes_review:让模型总结带某个标签的笔记
297
+ * @param input
298
+ */
299
+ @MCPPrompt('review', TagDTO, {title: 'Review notes', description: 'Summarize the notes of a tag and list what is left to do'})
300
+ public async review(input: TagDTO): Promise<string> {
301
+ const notes: string[] = this.notes.search('', input.tag).map((note: NoteDTO): string => `## ${note.title}\n\n${note.text}`)
302
+ return [`My notes tagged "${input.tag}":`, ...notes, 'Summarize them, then list what is left to do.'].join('\n\n')
303
+ }
304
+ }
305
+ ```
306
+
307
+ - `@Prefix({mcp: 'notes'})` 给工具和提示词的名字加上前缀:`add` 变成 `notes_add`。资源的 URI 不会加前缀。
308
+ - 模型根据工具的 `description` 决定什么时候调用它:要写清楚工具做什么、什么时候使用。
309
+ - `readOnly` 和 `destructive` 是给客户端的提示,客户端可能在执行删除之前先询问用户。
310
+ - 声明了 `output` 时,结果既以 JSON 文本给出,也作为结构化内容给出,客户端会按 DTO 的 Schema 检查它。这样的工具必须返回对象:`search` 返回 `{total, notes}`,而不是笔记数组。
311
+ - `note://{id}` 是一个模板:客户端填入 id,读取 `note://1`。返回的字符串就是资源的文本,类型为 `text/markdown`。
312
+ - 提示词返回一个字符串,作为一条用户消息;用户在客户端中选中这个提示词时,这条消息就会发出。
313
+
314
+ ## 6. 服务器的选项
315
+
316
+ **`src/config/Config.ts`**
317
+
318
+ ```typescript
319
+ import os from 'node:os'
320
+ import path from 'node:path'
321
+ import type {IncomingMessage} from 'node:http'
322
+ import type {ApplicationOptions} from 'lakutata'
323
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
324
+ import {Logger} from 'lakutata/com/logger'
325
+ import {httpMCPEntrypoint, type MCPAuthInfo, stdioMCPEntrypoint} from '@lakutata/mcp'
326
+ import {NoteStore} from '../components/NoteStore.js'
327
+ import {NoteController} from '../controllers/NoteController.js'
328
+
329
+ /**
330
+ * 给模型看的服务器说明
331
+ */
332
+ const instructions: string = 'The notes of the user. Search them before answering a question they may answer, and save a note when the user asks you to remember something.'
333
+
334
+ /**
335
+ * 通过 HTTP 时,如果环境变量中有 MCP_TOKEN,请求必须带上 "Authorization: Bearer <MCP_TOKEN>";抛出错误时响应 401
336
+ * @param request
337
+ */
338
+ function authenticate(request: IncomingMessage): MCPAuthInfo | undefined {
339
+ const token: string | undefined = process.env.MCP_TOKEN
340
+ if (!token) return undefined
341
+ if (request.headers.authorization !== `Bearer ${token}`) throw new Error('Invalid token')
342
+ return {token: token, clientId: 'assistant', scopes: []}
343
+ }
344
+
345
+ /**
346
+ * 服务器的选项
347
+ * @param file 保存笔记的 JSON 文件,默认取环境变量 NOTES_FILE,没有时为主目录下的 notes.json
348
+ * @param port 通过 HTTP 提供服务的端口,取环境变量 MCP_PORT;没有它时,服务器通过 stdio 通信
349
+ */
350
+ export function config(file: string = process.env.NOTES_FILE ?? path.join(os.homedir(), 'notes.json'), port: number | undefined = process.env.MCP_PORT ? Number(process.env.MCP_PORT) : undefined): ApplicationOptions {
351
+ return {
352
+ id: 'notes',
353
+ name: 'notes',
354
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
355
+ components: {
356
+ //通过 stdio 时,标准输出属于协议:日志写到标准错误
357
+ log: {class: Logger, destinations: [process.stderr]},
358
+ notes: {class: NoteStore, file: file},
359
+ entrypoint: buildEntrypoints({
360
+ controllers: [NoteController],
361
+ mcp: port === undefined
362
+ ? stdioMCPEntrypoint({instructions: instructions})
363
+ : httpMCPEntrypoint({port: port, authenticate: authenticate, instructions: instructions})
364
+ })
365
+ },
366
+ bootstrap: ['entrypoint']
367
+ }
368
+ }
369
+ ```
370
+
371
+ 这是模板中的 `Config.ts`,改动如下:
372
+
373
+ - 新增 `log` 组件,日志写到标准错误(见 [Logger](../Logger.md) 第 5 节):通过 stdio 时,标准输出承载协议的消息,往里面写一行日志就会破坏这些消息,所以其他任何代码都不能写标准输出,连 `console.log()` 也不行。模板中的 `App.ts` 保持不变,它通过这个组件记录启动日志;
374
+ - 新增 `notes` 组件和它的文件:环境变量 `NOTES_FILE`,没有时为主目录下的 `notes.json`。客户端会在它自己选择的目录中启动服务器,所以默认路径是绝对路径;
375
+ - 入口的 `mcp` 适配器(见 [Entrypoint](../Entrypoint.md)):默认是 `stdioMCPEntrypoint()`,设置了 `MCP_PORT` 时是监听 `127.0.0.1` 的 `httpMCPEntrypoint()`。如果同时设置了 `MCP_TOKEN`,`authenticate` 会要求请求带上它,没带的请求得到 401;
376
+ - `instructions`:客户端连接时收到这段说明,并交给模型。
377
+
378
+ ## 7. 运行
379
+
380
+ ```bash
381
+ npm run build
382
+ ```
383
+
384
+ 不用客户端也可以试用服务器:把协议消息写到它的标准输入,先初始化,再调用 `notes_add`。
385
+
386
+ ```bash
387
+ printf '%s\n' \
388
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"shell","version":"1.0.0"}}}' \
389
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
390
+ '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"notes_add","arguments":{"title":"Wi-Fi","text":"The password is on the back of the router.","tags":["home"]}}}' \
391
+ | NOTES_FILE=/tmp/notes.json node build/App.js 2>/dev/null
392
+ ```
393
+
394
+ ```
395
+ {"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{"listChanged":true},"resources":{"listChanged":true,"subscribe":true},"prompts":{"listChanged":true}},"serverInfo":{"name":"notes","version":"1.0.0"},"instructions":"The notes of the user. Search them before answering a question they may answer, and save a note when the user asks you to remember something."},"jsonrpc":"2.0","id":1}
396
+ {"result":{"content":[{"type":"text","text":"{\n \"id\": 1,\n \"title\": \"Wi-Fi\",\n \"text\": \"The password is on the back of the router.\",\n \"tags\": [\n \"home\"\n ],\n \"createdAt\": \"2026-10-17T14:56:05.346Z\"\n}"}],"structuredContent":{"id":1,"title":"Wi-Fi","text":"The password is on the back of the router.","tags":["home"],"createdAt":"2026-10-17T14:56:05.346Z"}},"jsonrpc":"2.0","id":2}
397
+ ```
398
+
399
+ 标准输出中只有响应,每行一条消息;启动日志写到了标准错误,这里把它丢弃了。输入关闭后,服务器就会退出。客户端要运行 `node build/App.js`,而不是 `npm start`:npm 会把它自己的输出(`> notes@0.0.1 start`)写到标准输出。
400
+
401
+ **在 Claude Code 中。** 注册一次服务器,供所有项目使用(`--scope user`),同时给出笔记文件的路径:
402
+
403
+ ```bash
404
+ claude mcp add --scope user notes -e NOTES_FILE=$HOME/notes.json -- node $PWD/build/App.js
405
+ claude mcp get notes
406
+ ```
407
+
408
+ `claude mcp get notes` 会启动服务器检查连接,并显示 `Status: ✔ Connected`。之后在对话中,Claude 就能把你让它记住的内容存下来,也能搜索你的笔记;工具的描述和服务器的说明会告诉它什么时候这样做。`/mcp__notes__notes_review home` 会发送这个提示词,并填入带 `home` 标签的笔记。
409
+
410
+ **在 Claude Desktop 和其他客户端中。** 通过 stdio 运行服务器的客户端,会在配置的 `mcpServers` 中列出各个服务器的启动命令。Claude Desktop 的配置文件是 `claude_desktop_config.json`(通过 Settings > Developer > Edit Config 打开),应用启动时读取它:
411
+
412
+ ```json
413
+ {
414
+ "mcpServers": {
415
+ "notes": {
416
+ "command": "node",
417
+ "args": ["/Users/me/notes/build/App.js"],
418
+ "env": {"NOTES_FILE": "/Users/me/notes.json"}
419
+ }
420
+ }
421
+ }
422
+ ```
423
+
424
+ 路径都要写绝对路径。如果客户端找不到 `node`(例如用 nvm 安装的 Node.js),就要写出它的完整路径,也就是 `which node` 的输出。
425
+
426
+ **通过 HTTP。** 这时服务器独立运行,客户端连接它的 URL:
427
+
428
+ ```bash
429
+ MCP_PORT=3000 MCP_TOKEN=change-me node build/App.js
430
+ ```
431
+
432
+ 在另一个终端中:
433
+
434
+ ```bash
435
+ curl -i -X POST localhost:3000/mcp -H 'Content-Type: application/json' -d '{}'
436
+ # HTTP/1.1 401 Unauthorized
437
+ # {"error":"unauthorized","error_description":"Invalid token"}
438
+
439
+ claude mcp add --transport http --scope user notes-http http://127.0.0.1:3000/mcp --header "Authorization: Bearer change-me"
440
+ ```
441
+
442
+ 不带令牌的请求会被拒绝;Claude Code 会发送 `--header` 给出的请求头。通过 HTTP 时,服务器使用 2026-07-28 版本的协议;使用 2025 年各版本协议的客户端也能连接,只是没有会话(见 [MCP](../MCP.md) 第 6 节)。
443
+
444
+ ## 8. 测试
445
+
446
+ 模板中的测试换成了针对服务器的测试,覆盖两种传输方式。通过 stdio 时,SDK 的 MCP 客户端像 Claude Code 一样把 `build/App.js` 作为子进程运行;通过 HTTP 时,服务器运行在测试进程中,端口为 18088:
447
+
448
+ **`src/tests/App.spec.ts`**
449
+
450
+ ```typescript
451
+ import {after, before, describe, it} from 'node:test'
452
+ import assert from 'node:assert'
453
+ import {mkdtempSync, readFileSync, rmSync} from 'node:fs'
454
+ import os from 'node:os'
455
+ import path from 'node:path'
456
+ import {fileURLToPath} from 'node:url'
457
+ import {Application} from 'lakutata'
458
+ import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'
459
+ import {StdioClientTransport} from '@modelcontextprotocol/client/stdio'
460
+ import {config} from '../config/Config.js'
461
+
462
+ const PORT: number = Number(process.env.TEST_PORT ?? 18088)
463
+
464
+ describe('notes over stdio', (): void => {
465
+
466
+ let directory: string
467
+ let client: Client
468
+ let logs: string = ''
469
+
470
+ before(async (): Promise<void> => {
471
+ directory = mkdtempSync(path.join(os.tmpdir(), 'notes-'))
472
+ //客户端像 Claude Code 一样把服务器作为子进程运行,并给它单独的笔记文件
473
+ const transport: StdioClientTransport = new StdioClientTransport({
474
+ command: process.execPath,
475
+ args: [fileURLToPath(new URL('../App.js', import.meta.url))],
476
+ env: {NOTES_FILE: path.join(directory, 'notes.json')},
477
+ stderr: 'pipe'
478
+ })
479
+ transport.stderr?.on('data', (chunk: Buffer): void => void (logs += chunk.toString()))
480
+ client = new Client({name: 'test', version: '1.0.0'})
481
+ await client.connect(transport)
482
+ })
483
+
484
+ after(async (): Promise<void> => {
485
+ await client?.close()
486
+ rmSync(directory, {recursive: true, force: true})
487
+ })
488
+
489
+ it('lists its tools, its resource and its prompt', async (): Promise<void> => {
490
+ assert.deepStrictEqual((await client.listTools()).tools.map((tool): string => tool.name).sort(), ['notes_add', 'notes_delete', 'notes_search'])
491
+ assert.deepStrictEqual((await client.listResourceTemplates()).resourceTemplates.map((template): string => template.uriTemplate), ['note://{id}'])
492
+ assert.deepStrictEqual((await client.listPrompts()).prompts.map((prompt): string => prompt.name), ['notes_review'])
493
+ })
494
+
495
+ it('saves the notes and finds them', async (): Promise<void> => {
496
+ const added = await client.callTool({name: 'notes_add', arguments: {title: 'Wi-Fi', text: 'The password is on the back of the router.', tags: ['home']}})
497
+ assert.strictEqual((added.structuredContent as { id: number }).id, 1)
498
+ await client.callTool({name: 'notes_add', arguments: {title: 'Quarterly report', text: 'Send it to Anna before Friday.', tags: ['work']}})
499
+ const found = await client.callTool({name: 'notes_search', arguments: {query: 'password'}})
500
+ const result = found.structuredContent as { total: number, notes: { title: string }[] }
501
+ assert.strictEqual(result.total, 1)
502
+ assert.strictEqual(result.notes[0].title, 'Wi-Fi')
503
+ //笔记已经写入文件
504
+ const saved: { title: string }[] = JSON.parse(readFileSync(path.join(directory, 'notes.json'), 'utf8'))
505
+ assert.deepStrictEqual(saved.map((note: { title: string }): string => note.title), ['Wi-Fi', 'Quarterly report'])
506
+ })
507
+
508
+ it('gives a note as a resource, and the notes of a tag as a prompt', async (): Promise<void> => {
509
+ const resource = await client.readResource({uri: 'note://2'})
510
+ assert.deepStrictEqual(resource.contents, [{uri: 'note://2', mimeType: 'text/markdown', text: '# Quarterly report\n\nSend it to Anna before Friday.\n\nTags: work\n'}])
511
+ const prompt = await client.getPrompt({name: 'notes_review', arguments: {tag: 'work'}})
512
+ assert.match((prompt.messages[0].content as { text: string }).text, /Send it to Anna before Friday/)
513
+ })
514
+
515
+ it('tells the model what went wrong', async (): Promise<void> => {
516
+ const missing = await client.callTool({name: 'notes_delete', arguments: {id: 9}})
517
+ assert.strictEqual(missing.isError, true)
518
+ assert.deepStrictEqual(missing.content, [{type: 'text', text: 'E_NOTE_NOT_FOUND: The note 9 does not exist'}])
519
+ const invalid = await client.callTool({name: 'notes_add', arguments: {title: '', text: 'Nothing'}})
520
+ assert.deepStrictEqual(invalid.content, [{type: 'text', text: 'E_INVALID_VALUE: "title" is not allowed to be empty'}])
521
+ })
522
+
523
+ it('writes its logs to the standard error', async (): Promise<void> => {
524
+ assert.match(logs, /notes started in development mode/)
525
+ })
526
+ })
527
+
528
+ describe('notes over HTTP', (): void => {
529
+
530
+ let directory: string
531
+ let app: Application
532
+
533
+ before(async (): Promise<void> => {
534
+ process.env.MCP_TOKEN = 'test-token'
535
+ directory = mkdtempSync(path.join(os.tmpdir(), 'notes-'))
536
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
537
+ Application.run(config(path.join(directory, 'notes.json'), PORT)).onLaunched((launched: Application): void => resolve(launched))
538
+ })
539
+ })
540
+
541
+ after(async (): Promise<void> => {
542
+ await app?.dispose()
543
+ rmSync(directory, {recursive: true, force: true})
544
+ })
545
+
546
+ it('refuses the clients without the token', async (): Promise<void> => {
547
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/mcp`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'})
548
+ assert.strictEqual(response.status, 401)
549
+ })
550
+
551
+ it('serves the clients with the token', async (): Promise<void> => {
552
+ const client: Client = new Client({name: 'test', version: '1.0.0'})
553
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/mcp`), {requestInit: {headers: {Authorization: 'Bearer test-token'}}}))
554
+ try {
555
+ await client.callTool({name: 'notes_add', arguments: {title: 'Parking', text: 'Level 3, row F.'}})
556
+ const found = await client.callTool({name: 'notes_search', arguments: {query: 'parking'}})
557
+ assert.strictEqual((found.structuredContent as { total: number }).total, 1)
558
+ } finally {
559
+ await client.close()
560
+ }
561
+ })
562
+ })
563
+ ```
564
+
565
+ - `env` 把 `NOTES_FILE` 加到 SDK 传给服务器的环境变量中(SDK 只传 `PATH` 等少数几个变量,而不是测试的全部环境),让服务器使用单独的文件。`stderr: 'pipe'` 收集服务器的日志。
566
+ - 调用失败时,`callTool()` 返回一个 `isError` 为 true 的结果,而不是抛出异常:模型像读取普通结果一样读取错误。
567
+
568
+ ```bash
569
+ npm test
570
+ ```
571
+
572
+ ```
573
+ ▶ notes over stdio
574
+ ✔ lists its tools, its resource and its prompt (4.520208ms)
575
+ ✔ saves the notes and finds them (11.459792ms)
576
+ ✔ gives a note as a resource, and the notes of a tag as a prompt (2.052833ms)
577
+ ✔ tells the model what went wrong (1.021584ms)
578
+ ✔ writes its logs to the standard error (0.061291ms)
579
+ ✔ notes over stdio (201.009875ms)
580
+ ▶ notes over HTTP
581
+ ✔ refuses the clients without the token (8.533542ms)
582
+ ✔ serves the clients with the token (18.563167ms)
583
+ ✔ notes over HTTP (55.262209ms)
584
+ ℹ tests 7
585
+ ℹ suites 2
586
+ ℹ pass 7
587
+ ℹ fail 0
588
+ ```
589
+
590
+ ## 9. 分发
591
+
592
+ ```bash
593
+ npm install --save-dev @lakutata/builder
594
+ echo '/dist' >> .gitignore
595
+ npx lakutata package bundle
596
+ ```
597
+
598
+ ```
599
+ ✔ dist/notes.cjs (0.9 MB): node dist/notes.cjs
600
+ ```
601
+
602
+ - 单文件包只有一个文件,不需要 `node_modules` 就能运行(见 [Packaging](../Packaging.md)),git 会忽略它。把它复制到任意位置,再把路径告诉客户端:`claude mcp add --scope user notes -- node /path/to/notes.cjs`。
603
+ - 每次调用都会完整地读写笔记文件,这对个人笔记来说足够了。笔记很多,或者有多个客户端同时写入时,请把笔记保存在数据库中(见 [Database](../Database.md))。
604
+ - 要通过 HTTP 为其他机器提供服务,请在服务器前面放一个带 TLS 的代理,并在 `allowedHosts` 中列出客户端连接时使用的主机名(见 [MCP](../MCP.md) 第 6 节)。
@@ -0,0 +1,14 @@
1
+ # 示例
2
+
3
+ 每个示例都构建一个完整的应用:从 `lakutata create` 的模板开始,一直到测试,包括要写的文件、要运行的命令,以及命令的输出。每一页的代码都经过检查:脚本根据页面创建项目并构建,再运行项目的测试。
4
+
5
+ | 示例 | 展示的内容 |
6
+ |---|---|
7
+ | [带数据库的 REST API](RestAPI.md) | `service` 模板、实体及其迁移(SQLite)、DTO 校验、带 HTTP 状态码的异常、分页、测试 |
8
+ | [命令行工具](CommandLineTool.md) | `cli` 模板、带参数和选项的命令(`@CLIAction` 及其 DTO)、校验错误、在终端中询问缺少的选项、退出码、测试、不需要 `node_modules` 的单文件包 |
9
+ | [带界面的 Web 应用](WebApp.md) | `web-react` 模板、`/api` 下的 HTTP API(数据放在组件中)、调用它的 React 页面(开发时经由 Vite 代理)、构建后由应用提供页面、单文件包把页面作为资源带上(`lakutata.resources`、`resourcesPath()`)、测试 |
10
+ | [桌面应用](DesktopApp.md) | `electron-react` 模板,一个笔记应用:主进程通过 IPC 提供的动作、系统对话框、向窗口推送的事件、不需要 Electron 就能运行的测试 |
11
+ | [服务之间的调用](ServiceCalls.md) | 两个 `service` 项目:`@ServiceAction` 的模式与 `@Prefix`、HTTP 服务传输方式、调用它的代理组件、服务之间的令牌、被调用方的拒绝传回调用方(409)、各服务的测试(替身运行在测试进程中) |
12
+ | [后台任务](BackgroundJobs.md) | `service` 模板、队列(内存驱动)及其 `@QueueAction`、重试和死信队列、借助锁只在一个实例中执行的 cron 定时任务、共用目录的两个实例、替换组件的测试 |
13
+ | [给 AI 助手用的 MCP 服务器](MCPServer.md) | `default` 模板、工具、资源和提示词(通过 stdio 和 HTTP 提供)、用 DTO 校验参数、在 Claude Code 和 Claude Desktop 中注册、用 SDK 的 MCP 客户端编写的测试 |
14
+ | [设备上的服务](DeviceService.md) | `service` 模板及其 `installer` 功能:定时任务、带选项的组件、监控器、用环境变量配置、由 deb/rpm 软件包安装的系统服务、测试 |