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.
- package/README.md +50 -30
- package/README.zh-CN.md +130 -0
- package/dist/cjs/exports/QueueComponent.d.ts +1 -0
- package/dist/cjs/exports/QueueComponent.js +20 -0
- package/dist/esm/exports/QueueComponent.js +1 -0
- package/dist/types/exports/QueueComponent.d.ts +1 -0
- package/doc/en/Application.md +107 -0
- package/doc/en/CLI.md +274 -0
- package/doc/en/Cache.md +379 -0
- package/doc/en/Command.md +110 -0
- package/doc/en/ContainerdIntegration.md +17 -16
- package/doc/en/Controllers.md +256 -0
- package/doc/en/DBus.md +291 -0
- package/doc/en/DTOValidation.md +72 -43
- package/doc/en/Database.md +188 -0
- package/doc/en/DatabaseDrivers.md +287 -0
- package/doc/en/Decorator.md +52 -40
- package/doc/en/DockerIntegration.md +7 -6
- package/doc/en/Entities.md +240 -0
- package/doc/en/Entrypoint.md +216 -0
- package/doc/en/Exceptions.md +174 -0
- package/doc/en/GRPC.md +251 -0
- package/doc/en/GettingStarted.md +165 -0
- package/doc/en/HTTP.md +507 -0
- package/doc/en/HelperFunctions.md +11 -11
- package/doc/en/I18n.md +119 -115
- package/doc/en/Injection.md +144 -0
- package/doc/en/Installers.md +280 -0
- package/doc/en/Logger.md +206 -0
- package/doc/en/MCP.md +293 -0
- package/doc/en/Middlewares.md +213 -0
- package/doc/en/Migrations.md +202 -0
- package/doc/en/MongoEmbedded.md +149 -0
- package/doc/en/Monitor.md +164 -0
- package/doc/en/Mutex.md +27 -25
- package/doc/en/NATS.md +246 -0
- package/doc/en/NativeAddons.md +212 -0
- package/doc/en/Objects.md +143 -0
- package/doc/en/Overview.md +62 -85
- package/doc/en/Packaging.md +193 -0
- package/doc/en/ProjectStructure.md +124 -0
- package/doc/en/Queue.md +269 -0
- package/doc/en/Schedules.md +217 -0
- package/doc/en/Security.md +10 -9
- package/doc/en/ServiceCalls.md +504 -0
- package/doc/en/Time.md +19 -20
- package/doc/en/UpgradingFrom1.md +435 -0
- package/doc/en/UpgradingFrom2.md +315 -0
- package/doc/en/Workspaces.md +189 -0
- package/doc/en/examples/BackgroundJobs.md +677 -0
- package/doc/en/examples/CommandLineTool.md +664 -0
- package/doc/en/examples/DesktopApp.md +809 -0
- package/doc/en/examples/DeviceService.md +607 -0
- package/doc/en/examples/MCPServer.md +653 -0
- package/doc/en/examples/README.md +16 -0
- package/doc/en/examples/RestAPI.md +490 -0
- package/doc/en/examples/ServiceCalls.md +829 -0
- package/doc/en/examples/WebApp.md +568 -0
- package/doc/zh/Application.md +75 -0
- package/doc/zh/CLI.md +250 -0
- package/doc/zh/Cache.md +328 -0
- package/doc/zh/Command.md +78 -0
- package/doc/zh/ContainerdIntegration.md +9 -9
- package/doc/zh/Controllers.md +228 -0
- package/doc/zh/DBus.md +257 -0
- package/doc/zh/DTOValidation.md +49 -19
- package/doc/zh/Database.md +175 -0
- package/doc/zh/DatabaseDrivers.md +261 -0
- package/doc/zh/Decorator.md +25 -18
- package/doc/zh/DockerIntegration.md +6 -6
- package/doc/zh/Entities.md +224 -0
- package/doc/zh/Entrypoint.md +193 -0
- package/doc/zh/Exceptions.md +158 -0
- package/doc/zh/GRPC.md +226 -0
- package/doc/zh/GettingStarted.md +148 -0
- package/doc/zh/HTTP.md +445 -0
- package/doc/zh/HelperFunctions.md +2 -2
- package/doc/zh/I18n.md +26 -26
- package/doc/zh/Injection.md +129 -0
- package/doc/zh/Installers.md +232 -0
- package/doc/zh/Logger.md +188 -0
- package/doc/zh/MCP.md +265 -0
- package/doc/zh/Middlewares.md +198 -0
- package/doc/zh/Migrations.md +178 -0
- package/doc/zh/MongoEmbedded.md +118 -0
- package/doc/zh/Monitor.md +145 -0
- package/doc/zh/Mutex.md +10 -10
- package/doc/zh/NATS.md +207 -0
- package/doc/zh/NativeAddons.md +180 -0
- package/doc/zh/Objects.md +119 -0
- package/doc/zh/Overview.md +46 -71
- package/doc/zh/Packaging.md +154 -0
- package/doc/zh/ProjectStructure.md +102 -0
- package/doc/zh/Queue.md +214 -0
- package/doc/zh/Schedules.md +193 -0
- package/doc/zh/Security.md +6 -6
- package/doc/zh/ServiceCalls.md +459 -0
- package/doc/zh/Time.md +2 -2
- package/doc/zh/UpgradingFrom1.md +347 -0
- package/doc/zh/UpgradingFrom2.md +230 -0
- package/doc/zh/Workspaces.md +150 -0
- package/doc/zh/examples/BackgroundJobs.md +618 -0
- package/doc/zh/examples/CommandLineTool.md +618 -0
- package/doc/zh/examples/DesktopApp.md +748 -0
- package/doc/zh/examples/DeviceService.md +546 -0
- package/doc/zh/examples/MCPServer.md +604 -0
- package/doc/zh/examples/README.md +14 -0
- package/doc/zh/examples/RestAPI.md +458 -0
- package/doc/zh/examples/ServiceCalls.md +765 -0
- package/doc/zh/examples/WebApp.md +520 -0
- package/package.json +46 -17
- package/doc/en/ActionPattern.md +0 -408
- package/doc/en/Core.md +0 -312
- package/doc/en/ORMIntegration.md +0 -341
- package/doc/zh/ActionPattern.md +0 -334
- package/doc/zh/Core.md +0 -256
- 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 |
|