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,653 @@
1
+ # An MCP server for AI assistants
2
+
3
+ This example builds a notes server for AI assistants: Claude Code, Claude Desktop or another MCP client runs it, and the
4
+ model uses it to save, search and delete the user's notes, kept in a JSON file. It starts from the `default` template
5
+ and shows the parts of an MCP server: tools whose arguments are validated by DTOs, a resource and a prompt, the stdio
6
+ transport for the clients that start the server themselves, the HTTP transport, and tests that use the SDK's MCP client.
7
+
8
+ | Name | Kind | What it does |
9
+ |---|---|---|
10
+ | `notes_add` | Tool | Saves a note: its title, its text and its tags |
11
+ | `notes_search` | Tool | Finds the notes by words and by tag, the newest first |
12
+ | `notes_delete` | Tool | Deletes a note |
13
+ | `note://{id}` | Resource | Gives a note in Markdown |
14
+ | `notes_review` | Prompt | Asks the model to summarize the notes with a tag |
15
+
16
+ ## 1. Create the project
17
+
18
+ ```bash
19
+ npx @lakutata/cli create --name notes --template default --yes
20
+ cd notes
21
+ npm install @lakutata/mcp
22
+ npm install --save-dev @modelcontextprotocol/client
23
+ npm uninstall express @types/express
24
+ rm src/controllers/ExampleController.ts src/dtos/GreetDTO.ts
25
+ ```
26
+
27
+ An MCP client runs the server as a single process, so the `default` template, which makes one application, fits it
28
+ ([ProjectStructure](../ProjectStructure.md)). `@lakutata/mcp` serves the actions of the application over MCP; it is not
29
+ part of `lakutata` ([MCP](../MCP.md)). The SDK's MCP client, `@modelcontextprotocol/client`, is only for the tests. The
30
+ server has no HTTP routes, so express and the example controller of the template are removed.
31
+
32
+ ## 2. The input and the results
33
+
34
+ **`src/dtos/NoteDTO.ts`**
35
+
36
+ ```typescript
37
+ import {DTO} from 'lakutata'
38
+ import {Expect} from 'lakutata/decorator/dto'
39
+
40
+ /**
41
+ * A note
42
+ */
43
+ export class NoteDTO extends DTO {
44
+ @Expect(DTO.Number().integer().required())
45
+ public id: number
46
+
47
+ @Expect(DTO.String().required())
48
+ public title: string
49
+
50
+ @Expect(DTO.String().required())
51
+ public text: string
52
+
53
+ @Expect(DTO.Array(DTO.String()).required())
54
+ public tags: string[]
55
+
56
+ @Expect(DTO.String().isoDate().required())
57
+ public createdAt: string
58
+ }
59
+
60
+ /**
61
+ * A note by its id (a number in the arguments of a tool, text in a URI, which strict(false) converts)
62
+ */
63
+ export class NoteIdDTO extends DTO {
64
+ @Expect(DTO.Number().integer().positive().strict(false).required().description('The id of the note'))
65
+ public id: number
66
+ }
67
+
68
+ /**
69
+ * A new note
70
+ */
71
+ export class AddNoteDTO extends DTO {
72
+ @Expect(DTO.String().min(1).max(200).required().description('A short title'))
73
+ public title: string
74
+
75
+ @Expect(DTO.String().min(1).max(20000).required().description('The text of the note, in Markdown'))
76
+ public text: string
77
+
78
+ @Expect(DTO.Array(DTO.String().min(1).max(30)).max(10).default([]).description('Tags, such as "home" or "work"'))
79
+ public tags: string[]
80
+ }
81
+
82
+ /**
83
+ * A search of the notes
84
+ */
85
+ export class SearchNotesDTO extends DTO {
86
+ @Expect(DTO.String().allow('').default('').description('The words that the title or the text must hold; empty for every note'))
87
+ public query: string
88
+
89
+ @Expect(DTO.String().optional().description('Only the notes that have this tag'))
90
+ public tag?: string
91
+
92
+ @Expect(DTO.Number().integer().min(1).max(50).default(10).description('The most notes to return'))
93
+ public limit: number
94
+ }
95
+
96
+ /**
97
+ * The notes found, the newest first
98
+ */
99
+ export class SearchResultDTO extends DTO {
100
+ @Expect(DTO.Number().integer().required().description('The number of the notes found'))
101
+ public total: number
102
+
103
+ @Expect(DTO.Array(NoteDTO.Schema()).required().description('The notes found, up to the limit'))
104
+ public notes: NoteDTO[]
105
+ }
106
+
107
+ /**
108
+ * The notes with a tag (the argument of a prompt, which is text)
109
+ */
110
+ export class TagDTO extends DTO {
111
+ @Expect(DTO.String().required().description('The tag of the notes'))
112
+ public tag: string
113
+ }
114
+ ```
115
+
116
+ The client receives the JSON schema of each tool's DTO, and the model calls the tool with arguments that follow it; the
117
+ DTO then validates them, as for every action ([DTOValidation](../DTOValidation.md)):
118
+
119
+ - `.description()` tells the model what an argument is; the defaults (`tags`, `query`, `limit`) are in the schema too;
120
+ - `NoteIdDTO` serves both a tool, whose arguments are JSON (the number `1`), and the resource, whose variables are text
121
+ (`'1'` in `note://1`): `.strict(false)` converts the text;
122
+ - `NoteDTO` and `SearchResultDTO` describe the results of two tools (their `output`, section 5);
123
+ - `TagDTO` holds the argument of the prompt, which is text, like every argument of a prompt.
124
+
125
+ ## 3. The exception
126
+
127
+ **`src/exceptions/NoteNotFoundException.ts`**
128
+
129
+ ```typescript
130
+ import {Exception} from 'lakutata'
131
+
132
+ /**
133
+ * The note does not exist
134
+ */
135
+ export class NoteNotFoundException extends Exception {
136
+ public errno: string = 'E_NOTE_NOT_FOUND'
137
+
138
+ public readonly statusCode: number = 404
139
+ }
140
+ ```
141
+
142
+ An exception has an `errno` and a status ([Exceptions](../Exceptions.md)). When a tool throws it, the model receives a
143
+ result marked as an error, `E_NOTE_NOT_FOUND: The note 9 does not exist`, and can correct its call. When it is thrown
144
+ while a resource is read, its status 404 turns it into the protocol error `-32602` (invalid params).
145
+
146
+ ## 4. The notes
147
+
148
+ **`src/components/NoteStore.ts`**
149
+
150
+ ```typescript
151
+ import {existsSync, readFileSync, renameSync, writeFileSync} from 'node:fs'
152
+ import {Component, DTO} from 'lakutata'
153
+ import {Configurable} from 'lakutata/decorator/di'
154
+ import {type NoteDTO} from '../dtos/NoteDTO.js'
155
+ import {NoteNotFoundException} from '../exceptions/NoteNotFoundException.js'
156
+
157
+ /**
158
+ * The notes, kept in a JSON file
159
+ */
160
+ export class NoteStore extends Component {
161
+
162
+ /**
163
+ * The JSON file of the notes, created with the first note
164
+ */
165
+ @Configurable(DTO.String().required())
166
+ protected readonly file: string
167
+
168
+ /**
169
+ * The notes in the file, read again at each call: the servers of several clients may share the file
170
+ */
171
+ protected read(): NoteDTO[] {
172
+ return existsSync(this.file) ? JSON.parse(readFileSync(this.file, 'utf8')) : []
173
+ }
174
+
175
+ /**
176
+ * Writes the notes to a temporary file, then renames it over the file, so the file is never half written
177
+ * @param notes
178
+ */
179
+ protected write(notes: NoteDTO[]): void {
180
+ const temporary: string = `${this.file}.${process.pid}.tmp`
181
+ writeFileSync(temporary, JSON.stringify(notes, null, 2))
182
+ renameSync(temporary, this.file)
183
+ }
184
+
185
+ /**
186
+ * Saves a new note
187
+ * @param title
188
+ * @param text
189
+ * @param tags
190
+ */
191
+ public add(title: string, text: string, tags: string[]): NoteDTO {
192
+ const notes: NoteDTO[] = this.read()
193
+ const id: number = Math.max(0, ...notes.map((note: NoteDTO): number => note.id)) + 1
194
+ const note: NoteDTO = {id: id, title: title, text: text, tags: tags, createdAt: new Date().toISOString()} as NoteDTO
195
+ this.write([...notes, note])
196
+ return note
197
+ }
198
+
199
+ /**
200
+ * A note
201
+ * @param id
202
+ */
203
+ public get(id: number): NoteDTO {
204
+ const note: NoteDTO | undefined = this.read().find((note: NoteDTO): boolean => note.id === id)
205
+ if (!note) throw new NoteNotFoundException(`The note ${id} does not exist`)
206
+ return note
207
+ }
208
+
209
+ /**
210
+ * The notes that have the tag and whose title or text contains every word of the query, the newest first
211
+ * @param query
212
+ * @param tag
213
+ */
214
+ public search(query: string, tag?: string): NoteDTO[] {
215
+ const words: string[] = query.toLowerCase().split(/\s+/).filter(Boolean)
216
+ return this.read()
217
+ .filter((note: NoteDTO): boolean => tag === undefined || note.tags.includes(tag))
218
+ .filter((note: NoteDTO): boolean => words.every((word: string): boolean => `${note.title} ${note.text}`.toLowerCase().includes(word)))
219
+ .reverse()
220
+ }
221
+
222
+ /**
223
+ * Deletes a note
224
+ * @param id
225
+ */
226
+ public remove(id: number): NoteDTO {
227
+ const note: NoteDTO = this.get(id)
228
+ this.write(this.read().filter((other: NoteDTO): boolean => other.id !== id))
229
+ return note
230
+ }
231
+ }
232
+ ```
233
+
234
+ - `NoteStore` is a component: one instance for the whole application, while a controller is created for each request
235
+ ([Objects](../Objects.md)).
236
+ - `@Configurable` makes `file` an option of the component: its value is given where the component is declared
237
+ (section 6) and validated when the application launches ([Injection](../Injection.md)).
238
+ - Each client runs its own server: given the same file, Claude Code and Claude Desktop run two processes that share it.
239
+ So the file is read at each call instead of being kept in memory, and the notes are written whole to a temporary file
240
+ that is then renamed over it.
241
+
242
+ ## 5. The controller
243
+
244
+ **`src/controllers/NoteController.ts`**
245
+
246
+ ```typescript
247
+ import {Controller} from 'lakutata/com/entrypoint'
248
+ import {Prefix} from 'lakutata/decorator/ctrl'
249
+ import {Inject} from 'lakutata/decorator/di'
250
+ import {MCPPrompt, MCPResource, MCPTool} from '@lakutata/mcp'
251
+ import {type NoteStore} from '../components/NoteStore.js'
252
+ import {AddNoteDTO, NoteDTO, NoteIdDTO, SearchNotesDTO, SearchResultDTO, TagDTO} from '../dtos/NoteDTO.js'
253
+
254
+ @Prefix({mcp: 'notes'})
255
+ export class NoteController extends Controller {
256
+
257
+ /**
258
+ * The notes (the component "notes" of the options)
259
+ */
260
+ @Inject('notes')
261
+ protected readonly notes: NoteStore
262
+
263
+ /**
264
+ * The tool notes_add
265
+ * @param input
266
+ */
267
+ @MCPTool('add', AddNoteDTO, {
268
+ description: 'Save a note. Use it when the user asks you to remember something.',
269
+ output: NoteDTO
270
+ })
271
+ public async add(input: AddNoteDTO): Promise<NoteDTO> {
272
+ return this.notes.add(input.title, input.text, input.tags)
273
+ }
274
+
275
+ /**
276
+ * The tool notes_search
277
+ * @param input
278
+ */
279
+ @MCPTool('search', SearchNotesDTO, {
280
+ description: 'Search the notes of the user by words and by tag. Use it before answering a question that the notes may answer.',
281
+ readOnly: true,
282
+ output: SearchResultDTO
283
+ })
284
+ public async search(input: SearchNotesDTO): Promise<SearchResultDTO> {
285
+ const found: NoteDTO[] = this.notes.search(input.query, input.tag)
286
+ return {total: found.length, notes: found.slice(0, input.limit)} as SearchResultDTO
287
+ }
288
+
289
+ /**
290
+ * The tool notes_delete
291
+ * @param input
292
+ */
293
+ @MCPTool('delete', NoteIdDTO, {description: 'Delete a note', destructive: true})
294
+ public async delete(input: NoteIdDTO): Promise<string> {
295
+ const note: NoteDTO = this.notes.remove(input.id)
296
+ return `The note ${note.id} (${note.title}) is deleted`
297
+ }
298
+
299
+ /**
300
+ * The resource note://1: a note, in Markdown
301
+ * @param input
302
+ */
303
+ @MCPResource('note://{id}', NoteIdDTO, {resourceName: 'note', mimeType: 'text/markdown', description: 'A note, in Markdown'})
304
+ public async note(input: NoteIdDTO): Promise<string> {
305
+ const note: NoteDTO = this.notes.get(input.id)
306
+ return `# ${note.title}\n\n${note.text}\n\nTags: ${note.tags.join(', ') || 'none'}\n`
307
+ }
308
+
309
+ /**
310
+ * The prompt notes_review: a summary of the notes with a tag
311
+ * @param input
312
+ */
313
+ @MCPPrompt('review', TagDTO, {title: 'Review notes', description: 'Summarize the notes of a tag and list what is left to do'})
314
+ public async review(input: TagDTO): Promise<string> {
315
+ const notes: string[] = this.notes.search('', input.tag).map((note: NoteDTO): string => `## ${note.title}\n\n${note.text}`)
316
+ return [`My notes tagged "${input.tag}":`, ...notes, 'Summarize them, then list what is left to do.'].join('\n\n')
317
+ }
318
+ }
319
+ ```
320
+
321
+ - `@Prefix({mcp: 'notes'})` prefixes the names of the tools and of the prompt: `add` becomes `notes_add`. The URIs of
322
+ the resources are never prefixed.
323
+ - The `description` of a tool is what the model reads to decide when to call it: say what the tool does and when to use
324
+ it.
325
+ - `readOnly` and `destructive` are hints for the client, which may ask the user before a call that deletes.
326
+ - With `output`, the result is sent both as JSON text and as structured content, which the client checks against the
327
+ schema of the DTO. Such a tool must return an object: `search` returns `{total, notes}`, not an array of notes.
328
+ - `note://{id}` is a template: the client fills in the id and reads `note://1`. The string returned is the text of the
329
+ resource, of type `text/markdown`.
330
+ - The prompt returns a string, which becomes a single user message, sent when the user picks the prompt in the client.
331
+
332
+ ## 6. The options of the server
333
+
334
+ **`src/config/Config.ts`**
335
+
336
+ ```typescript
337
+ import os from 'node:os'
338
+ import path from 'node:path'
339
+ import type {IncomingMessage} from 'node:http'
340
+ import type {ApplicationOptions} from 'lakutata'
341
+ import {buildEntrypoints} from 'lakutata/com/entrypoint'
342
+ import {Logger} from 'lakutata/com/logger'
343
+ import {httpMCPEntrypoint, type MCPAuthInfo, stdioMCPEntrypoint} from '@lakutata/mcp'
344
+ import {NoteStore} from '../components/NoteStore.js'
345
+ import {NoteController} from '../controllers/NoteController.js'
346
+
347
+ /**
348
+ * What the model is told about the server
349
+ */
350
+ 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.'
351
+
352
+ /**
353
+ * Over HTTP, when MCP_TOKEN is set, the requests must carry "Authorization: Bearer <MCP_TOKEN>"; throwing answers 401
354
+ * @param request
355
+ */
356
+ function authenticate(request: IncomingMessage): MCPAuthInfo | undefined {
357
+ const token: string | undefined = process.env.MCP_TOKEN
358
+ if (!token) return undefined
359
+ if (request.headers.authorization !== `Bearer ${token}`) throw new Error('Invalid token')
360
+ return {token: token, clientId: 'assistant', scopes: []}
361
+ }
362
+
363
+ /**
364
+ * The options of the server
365
+ * @param file the JSON file of the notes, the NOTES_FILE environment variable or notes.json in the home directory by
366
+ * default
367
+ * @param port the port to serve over HTTP, the MCP_PORT environment variable; without it, the server speaks over stdio
368
+ */
369
+ 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 {
370
+ return {
371
+ id: 'notes',
372
+ name: 'notes',
373
+ mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
374
+ components: {
375
+ //The standard output belongs to the protocol over stdio: the logs go to the standard error
376
+ log: {class: Logger, destinations: [process.stderr]},
377
+ notes: {class: NoteStore, file: file},
378
+ entrypoint: buildEntrypoints({
379
+ controllers: [NoteController],
380
+ mcp: port === undefined
381
+ ? stdioMCPEntrypoint({instructions: instructions})
382
+ : httpMCPEntrypoint({port: port, authenticate: authenticate, instructions: instructions})
383
+ })
384
+ },
385
+ bootstrap: ['entrypoint']
386
+ }
387
+ }
388
+ ```
389
+
390
+ This is the `Config.ts` of the template, with:
391
+
392
+ - the `log` component, which writes to the standard error ([Logger](../Logger.md), section 5): over stdio, the standard
393
+ output carries the messages of the protocol, and a log line there would break them. Nothing else may write there, not
394
+ even `console.log()`. The `App.ts` of the template, unchanged, logs the startup through this component;
395
+ - the `notes` component and its file: `NOTES_FILE`, or `notes.json` in the home directory. The client starts the server
396
+ in a directory of its choice, so the default path is absolute;
397
+ - the `mcp` adapter of the entrypoint ([Entrypoint](../Entrypoint.md)): `stdioMCPEntrypoint()` by default, and
398
+ `httpMCPEntrypoint()` on `127.0.0.1` when `MCP_PORT` is set. When `MCP_TOKEN` is set too, `authenticate` requires it,
399
+ and a request without it is answered with 401;
400
+ - `instructions` for the model, which the client receives when it connects.
401
+
402
+ ## 7. Run it
403
+
404
+ ```bash
405
+ npm run build
406
+ ```
407
+
408
+ You can try the server without a client by writing the messages of the protocol to its standard input: the
409
+ initialization, then a call to `notes_add`.
410
+
411
+ ```bash
412
+ printf '%s\n' \
413
+ '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"shell","version":"1.0.0"}}}' \
414
+ '{"jsonrpc":"2.0","method":"notifications/initialized"}' \
415
+ '{"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"]}}}' \
416
+ | NOTES_FILE=/tmp/notes.json node build/App.js 2>/dev/null
417
+ ```
418
+
419
+ ```
420
+ {"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}
421
+ {"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}
422
+ ```
423
+
424
+ The standard output holds only the responses, one message per line; the startup log went to the standard error, which is
425
+ discarded here. The server exits once its input is closed. A client must run `node build/App.js`, not `npm start`: npm
426
+ writes its own lines (`> notes@0.0.1 start`) to the standard output.
427
+
428
+ **In Claude Code.** Register the server once for all your projects (`--scope user`), with the path of the notes file:
429
+
430
+ ```bash
431
+ claude mcp add --scope user notes -e NOTES_FILE=$HOME/notes.json -- node $PWD/build/App.js
432
+ claude mcp get notes
433
+ ```
434
+
435
+ `claude mcp get notes` starts the server to check it, and shows `Status: ✔ Connected`. In a conversation, Claude can
436
+ then save what you ask it to remember and search your notes; the descriptions of the tools and the instructions of the
437
+ server tell it when. `/mcp__notes__notes_review home` sends the prompt, filled with the notes tagged `home`.
438
+
439
+ **In Claude Desktop and other clients.** A client that runs its servers over stdio lists their commands in its
440
+ configuration, under `mcpServers`. For Claude Desktop, this is the file `claude_desktop_config.json`
441
+ (Settings > Developer > Edit Config), read when the application starts:
442
+
443
+ ```json
444
+ {
445
+ "mcpServers": {
446
+ "notes": {
447
+ "command": "node",
448
+ "args": ["/Users/me/notes/build/App.js"],
449
+ "env": {"NOTES_FILE": "/Users/me/notes.json"}
450
+ }
451
+ }
452
+ }
453
+ ```
454
+
455
+ The paths must be absolute. A client that cannot find `node` (installed with nvm, for example) needs its full path,
456
+ which `which node` prints.
457
+
458
+ **Over HTTP.** The server then runs on its own, and the clients connect to its URL:
459
+
460
+ ```bash
461
+ MCP_PORT=3000 MCP_TOKEN=change-me node build/App.js
462
+ ```
463
+
464
+ In another terminal:
465
+
466
+ ```bash
467
+ curl -i -X POST localhost:3000/mcp -H 'Content-Type: application/json' -d '{}'
468
+ # HTTP/1.1 401 Unauthorized
469
+ # {"error":"unauthorized","error_description":"Invalid token"}
470
+
471
+ claude mcp add --transport http --scope user notes-http http://127.0.0.1:3000/mcp --header "Authorization: Bearer change-me"
472
+ ```
473
+
474
+ A request without the token is refused; Claude Code sends the header given with `--header`. Over HTTP, the server speaks
475
+ the 2026-07-28 revision of the protocol, and serves the clients of the 2025 revisions without sessions
476
+ ([MCP](../MCP.md), section 6).
477
+
478
+ ## 8. Test it
479
+
480
+ The test of the template is replaced by a test of the server over its two transports. Over stdio, the SDK's MCP client
481
+ runs `build/App.js` as a child process, as Claude Code does; over HTTP, the server runs in the test process, on
482
+ port 18088:
483
+
484
+ **`src/tests/App.spec.ts`**
485
+
486
+ ```typescript
487
+ import {after, before, describe, it} from 'node:test'
488
+ import assert from 'node:assert'
489
+ import {mkdtempSync, readFileSync, rmSync} from 'node:fs'
490
+ import os from 'node:os'
491
+ import path from 'node:path'
492
+ import {fileURLToPath} from 'node:url'
493
+ import {Application} from 'lakutata'
494
+ import {Client, StreamableHTTPClientTransport} from '@modelcontextprotocol/client'
495
+ import {StdioClientTransport} from '@modelcontextprotocol/client/stdio'
496
+ import {config} from '../config/Config.js'
497
+
498
+ const PORT: number = Number(process.env.TEST_PORT ?? 18088)
499
+
500
+ describe('notes over stdio', (): void => {
501
+
502
+ let directory: string
503
+ let client: Client
504
+ let logs: string = ''
505
+
506
+ before(async (): Promise<void> => {
507
+ directory = mkdtempSync(path.join(os.tmpdir(), 'notes-'))
508
+ //The client runs the server as a child process, as Claude Code does, with its own notes file
509
+ const transport: StdioClientTransport = new StdioClientTransport({
510
+ command: process.execPath,
511
+ args: [fileURLToPath(new URL('../App.js', import.meta.url))],
512
+ env: {NOTES_FILE: path.join(directory, 'notes.json')},
513
+ stderr: 'pipe'
514
+ })
515
+ transport.stderr?.on('data', (chunk: Buffer): void => void (logs += chunk.toString()))
516
+ client = new Client({name: 'test', version: '1.0.0'})
517
+ await client.connect(transport)
518
+ })
519
+
520
+ after(async (): Promise<void> => {
521
+ await client?.close()
522
+ rmSync(directory, {recursive: true, force: true})
523
+ })
524
+
525
+ it('lists its tools, its resource and its prompt', async (): Promise<void> => {
526
+ assert.deepStrictEqual((await client.listTools()).tools.map((tool): string => tool.name).sort(), ['notes_add', 'notes_delete', 'notes_search'])
527
+ assert.deepStrictEqual((await client.listResourceTemplates()).resourceTemplates.map((template): string => template.uriTemplate), ['note://{id}'])
528
+ assert.deepStrictEqual((await client.listPrompts()).prompts.map((prompt): string => prompt.name), ['notes_review'])
529
+ })
530
+
531
+ it('saves the notes and finds them', async (): Promise<void> => {
532
+ const added = await client.callTool({name: 'notes_add', arguments: {title: 'Wi-Fi', text: 'The password is on the back of the router.', tags: ['home']}})
533
+ assert.strictEqual((added.structuredContent as { id: number }).id, 1)
534
+ await client.callTool({name: 'notes_add', arguments: {title: 'Quarterly report', text: 'Send it to Anna before Friday.', tags: ['work']}})
535
+ const found = await client.callTool({name: 'notes_search', arguments: {query: 'password'}})
536
+ const result = found.structuredContent as { total: number, notes: { title: string }[] }
537
+ assert.strictEqual(result.total, 1)
538
+ assert.strictEqual(result.notes[0].title, 'Wi-Fi')
539
+ //The notes are in the file
540
+ const saved: { title: string }[] = JSON.parse(readFileSync(path.join(directory, 'notes.json'), 'utf8'))
541
+ assert.deepStrictEqual(saved.map((note: { title: string }): string => note.title), ['Wi-Fi', 'Quarterly report'])
542
+ })
543
+
544
+ it('gives a note as a resource, and the notes of a tag as a prompt', async (): Promise<void> => {
545
+ const resource = await client.readResource({uri: 'note://2'})
546
+ assert.deepStrictEqual(resource.contents, [{uri: 'note://2', mimeType: 'text/markdown', text: '# Quarterly report\n\nSend it to Anna before Friday.\n\nTags: work\n'}])
547
+ const prompt = await client.getPrompt({name: 'notes_review', arguments: {tag: 'work'}})
548
+ assert.match((prompt.messages[0].content as { text: string }).text, /Send it to Anna before Friday/)
549
+ })
550
+
551
+ it('tells the model what went wrong', async (): Promise<void> => {
552
+ const missing = await client.callTool({name: 'notes_delete', arguments: {id: 9}})
553
+ assert.strictEqual(missing.isError, true)
554
+ assert.deepStrictEqual(missing.content, [{type: 'text', text: 'E_NOTE_NOT_FOUND: The note 9 does not exist'}])
555
+ const invalid = await client.callTool({name: 'notes_add', arguments: {title: '', text: 'Nothing'}})
556
+ assert.deepStrictEqual(invalid.content, [{type: 'text', text: 'E_INVALID_VALUE: "title" is not allowed to be empty'}])
557
+ })
558
+
559
+ it('writes its logs to the standard error', async (): Promise<void> => {
560
+ assert.match(logs, /notes started in development mode/)
561
+ })
562
+ })
563
+
564
+ describe('notes over HTTP', (): void => {
565
+
566
+ let directory: string
567
+ let app: Application
568
+
569
+ before(async (): Promise<void> => {
570
+ process.env.MCP_TOKEN = 'test-token'
571
+ directory = mkdtempSync(path.join(os.tmpdir(), 'notes-'))
572
+ app = await new Promise<Application>((resolve: (app: Application) => void): void => {
573
+ Application.run(config(path.join(directory, 'notes.json'), PORT)).onLaunched((launched: Application): void => resolve(launched))
574
+ })
575
+ })
576
+
577
+ after(async (): Promise<void> => {
578
+ await app?.dispose()
579
+ rmSync(directory, {recursive: true, force: true})
580
+ })
581
+
582
+ it('refuses the clients without the token', async (): Promise<void> => {
583
+ const response: Response = await fetch(`http://127.0.0.1:${PORT}/mcp`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'})
584
+ assert.strictEqual(response.status, 401)
585
+ })
586
+
587
+ it('serves the clients with the token', async (): Promise<void> => {
588
+ const client: Client = new Client({name: 'test', version: '1.0.0'})
589
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${PORT}/mcp`), {requestInit: {headers: {Authorization: 'Bearer test-token'}}}))
590
+ try {
591
+ await client.callTool({name: 'notes_add', arguments: {title: 'Parking', text: 'Level 3, row F.'}})
592
+ const found = await client.callTool({name: 'notes_search', arguments: {query: 'parking'}})
593
+ assert.strictEqual((found.structuredContent as { total: number }).total, 1)
594
+ } finally {
595
+ await client.close()
596
+ }
597
+ })
598
+ })
599
+ ```
600
+
601
+ - `env` adds `NOTES_FILE` to the variables that the SDK passes to the server (a few, such as `PATH`, not the whole
602
+ environment of the test), so that the server uses its own file. `stderr: 'pipe'` collects the logs of the server.
603
+ - For a failed call, `callTool()` returns a result whose `isError` is true instead of throwing: the model reads the
604
+ error like any other result.
605
+
606
+ ```bash
607
+ npm test
608
+ ```
609
+
610
+ ```
611
+ ▶ notes over stdio
612
+ ✔ lists its tools, its resource and its prompt (4.520208ms)
613
+ ✔ saves the notes and finds them (11.459792ms)
614
+ ✔ gives a note as a resource, and the notes of a tag as a prompt (2.052833ms)
615
+ ✔ tells the model what went wrong (1.021584ms)
616
+ ✔ writes its logs to the standard error (0.061291ms)
617
+ ✔ notes over stdio (201.009875ms)
618
+ ▶ notes over HTTP
619
+ ✔ refuses the clients without the token (8.533542ms)
620
+ ✔ serves the clients with the token (18.563167ms)
621
+ ✔ notes over HTTP (55.262209ms)
622
+ ℹ tests 7
623
+ ℹ suites 2
624
+ ℹ pass 7
625
+ ℹ fail 0
626
+ ```
627
+
628
+ ## 9. Share it
629
+
630
+ ```bash
631
+ npm install --save-dev @lakutata/builder
632
+ echo '/dist' >> .gitignore
633
+ npx lakutata package bundle
634
+ ```
635
+
636
+ ```
637
+ ✔ dist/notes.cjs (0.9 MB): node dist/notes.cjs
638
+ ```
639
+
640
+ - The bundle is a single file that runs without `node_modules` ([Packaging](../Packaging.md)); git ignores it. Copy it
641
+ anywhere and give its path to the clients: `claude mcp add --scope user notes -- node /path/to/notes.cjs`.
642
+ - The notes are read and written whole at each call, which suits the notes of one person. For many notes, or for several
643
+ clients that write at once, keep them in a database ([Database](../Database.md)).
644
+ - To serve other machines over HTTP, put a proxy with TLS in front of the server, and list in `allowedHosts` the host
645
+ name that the clients connect to ([MCP](../MCP.md), section 6).
646
+
647
+ <!--
648
+ check:
649
+ create: notes default
650
+ remove: src/controllers/ExampleController.ts src/dtos/GreetDTO.ts
651
+ run: npm test
652
+ run: npx lakutata package bundle
653
+ -->
@@ -0,0 +1,16 @@
1
+ # Examples
2
+
3
+ Each example builds a whole application, from a template of `lakutata create` to its tests: the files to write, the
4
+ commands to run and what they print. The code of every page is checked: a script creates the project from the page,
5
+ builds it and runs its tests.
6
+
7
+ | Example | What it shows |
8
+ |---|---|
9
+ | [A REST API with a database](RestAPI.md) | The `service` template, an entity and its migration (SQLite), DTO validation, an exception with its HTTP status, pagination, tests |
10
+ | [A command line tool](CommandLineTool.md) | The `cli` template, commands with arguments and options (`@CLIAction` and their DTOs), the validation errors, asking for a missing option in the terminal, the exit codes, tests, a bundle that runs without `node_modules` |
11
+ | [A web application with a user interface](WebApp.md) | The `web-react` template, an HTTP API under `/api` with its data in a component, a React page that calls it (through the Vite proxy in development), the page served by the built application and from the resources of a bundle (`lakutata.resources`, `resourcesPath()`), tests |
12
+ | [A desktop application](DesktopApp.md) | The `electron-react` template, a notes app: the actions of the main process called over IPC, a system dialog, events pushed to the window, tests that run without Electron |
13
+ | [Calls between services](ServiceCalls.md) | Two `service` projects: `@ServiceAction` patterns with `@Prefix`, the HTTP service transport, a proxy component that calls it, a token between the services, the callee's refusal passed back to the caller (409), the tests of each service with a stand-in in the test process |
14
+ | [Background jobs](BackgroundJobs.md) | The `service` template, a queue (memory driver) and its `@QueueAction`, retries and the dead letter queue, a cron schedule run in one instance with a lock, two instances sharing a directory, tests that replace a component |
15
+ | [An MCP server for AI assistants](MCPServer.md) | The `default` template, tools, a resource and a prompt served over stdio and HTTP, arguments validated by DTOs, registration in Claude Code and Claude Desktop, tests with the SDK's MCP client |
16
+ | [A service on a device](DeviceService.md) | The `service` template with its `installer` feature: a scheduled action, components with options, the monitors, configuration through environment variables, a system service installed by deb/rpm packages, tests |