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
package/doc/en/MCP.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# Lakutata MCP
|
|
2
|
+
|
|
3
|
+
The Model Context Protocol (MCP) lets an AI assistant use an application: call its **tools**, read its **resources**
|
|
4
|
+
and fill its **prompts**. The package `@lakutata/mcp` serves controller methods as these, over the standard input and
|
|
5
|
+
output of a child process or over HTTP. It is not part of `lakutata`:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install lakutata @lakutata/mcp
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
| Declared with | What it is | Who uses it |
|
|
12
|
+
|---|---|---|
|
|
13
|
+
| `@MCPTool(name)` | An action the model calls with arguments | The model, when it decides to |
|
|
14
|
+
| `@MCPResource(uri)` | Data read by its URI | The client, to give it to the model |
|
|
15
|
+
| `@MCPPrompt(name)` | A message template filled with arguments | The user, by choosing it in the client |
|
|
16
|
+
|
|
17
|
+
## 1. A server over stdio
|
|
18
|
+
|
|
19
|
+
```typescript
|
|
20
|
+
import {Application, DTO, Exception} from 'lakutata'
|
|
21
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
22
|
+
import {Prefix} from 'lakutata/decorator/ctrl'
|
|
23
|
+
import {Expect} from 'lakutata/decorator/dto'
|
|
24
|
+
import {Logger} from 'lakutata/com/logger'
|
|
25
|
+
import {MCPPrompt, MCPResource, MCPTool, stdioMCPEntrypoint} from '@lakutata/mcp'
|
|
26
|
+
|
|
27
|
+
class DeviceSnDTO extends DTO {
|
|
28
|
+
@Expect(DTO.String().required().description('The serial number of the device'))
|
|
29
|
+
public sn: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
class StatusDTO extends DTO {
|
|
33
|
+
@Expect(DTO.String().required())
|
|
34
|
+
public sn: string
|
|
35
|
+
|
|
36
|
+
@Expect(DTO.Boolean().required())
|
|
37
|
+
public online: boolean
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
class OfflineException extends Exception {
|
|
41
|
+
public errno: string = 'E_OFFLINE'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Prefix({mcp: 'device'})
|
|
45
|
+
class DeviceController extends Controller {
|
|
46
|
+
//The tool device_status
|
|
47
|
+
@MCPTool('status', DeviceSnDTO, {description: 'The online status of a device', readOnly: true, output: StatusDTO})
|
|
48
|
+
public async status(input: DeviceSnDTO): Promise<StatusDTO> {
|
|
49
|
+
return {sn: input.sn, online: input.sn !== 'off'} as StatusDTO
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
@MCPTool('reboot', DeviceSnDTO, {description: 'Reboot a device', destructive: true})
|
|
53
|
+
public async reboot(input: DeviceSnDTO): Promise<string> {
|
|
54
|
+
if (input.sn === 'off') throw new OfflineException('The device {sn} is offline', {sn: input.sn})
|
|
55
|
+
return `Rebooting ${input.sn}`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
//The resource of each device: device://A1/status
|
|
59
|
+
@MCPResource('device://{sn}/status', DeviceSnDTO, {mimeType: 'application/json', description: 'The status of a device'})
|
|
60
|
+
public async statusResource(input: DeviceSnDTO): Promise<StatusDTO> {
|
|
61
|
+
return {sn: input.sn, online: true} as StatusDTO
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
//The prompt device_diagnose
|
|
65
|
+
@MCPPrompt('diagnose', DeviceSnDTO, {description: 'Diagnose the failures of a device'})
|
|
66
|
+
public async diagnose(input: DeviceSnDTO): Promise<string> {
|
|
67
|
+
return `Diagnose the device ${input.sn} from its status and its logs.`
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
Application.run(() => ({
|
|
72
|
+
id: 'devices.app',
|
|
73
|
+
name: 'Devices',
|
|
74
|
+
components: {
|
|
75
|
+
//The standard output belongs to the protocol: the logs go to the standard error
|
|
76
|
+
log: {class: Logger, destinations: [process.stderr]},
|
|
77
|
+
entrypoint: buildEntrypoints({
|
|
78
|
+
controllers: [DeviceController],
|
|
79
|
+
mcp: stdioMCPEntrypoint({instructions: 'Check the status of a device before rebooting it.'})
|
|
80
|
+
})
|
|
81
|
+
},
|
|
82
|
+
bootstrap: ['entrypoint']
|
|
83
|
+
}))
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
- The client runs the application as its child process, with a command such as `node build/App.js`, and speaks to it
|
|
87
|
+
on its standard input and output. The application exits once the client closes its input (`exitOnClose: false`
|
|
88
|
+
keeps it running).
|
|
89
|
+
- The standard output carries the messages of the protocol: write the logs to the standard error, as above, and never
|
|
90
|
+
call `console.log()`.
|
|
91
|
+
- The options of `stdioMCPEntrypoint()`: `serverName` (the name of the application by default), `serverVersion`
|
|
92
|
+
(`'1.0.0'`), `instructions` for the model, `stdin` and `stdout`, `exitOnClose`, `name` and `middlewares`.
|
|
93
|
+
- Over stdio, the server speaks the revisions of 2025 of the protocol.
|
|
94
|
+
|
|
95
|
+
## 2. Tools
|
|
96
|
+
|
|
97
|
+
```text
|
|
98
|
+
@MCPTool(name, DTO?, options?)
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
| Option | Meaning |
|
|
102
|
+
|---|---|
|
|
103
|
+
| `description` | What the tool does, for the model; without it, the model sees `ControllerClass:method` |
|
|
104
|
+
| `title` | A name to display |
|
|
105
|
+
| `readOnly`, `destructive`, `idempotent`, `openWorld` | Hints for the client: the tool changes nothing, makes changes that cannot be undone, gives the same result when called again, or reaches outside systems |
|
|
106
|
+
| `output` | The DTO of the result: its JSON schema is sent, and the result is given as structured content too |
|
|
107
|
+
| `acl`, `rule`, `middlewares`… | The options of every action (see [Controllers](Controllers.md), section 3) |
|
|
108
|
+
|
|
109
|
+
- A name has 1 to 128 letters, digits, `_`, `-` or `.`. `@Prefix({mcp: 'device'})` prefixes the names of the tools and
|
|
110
|
+
of the prompts of a controller: `status` becomes `device_status`.
|
|
111
|
+
- The arguments are described by the JSON schema of the DTO; `.description()` describes an argument to the model. The
|
|
112
|
+
DTO validates them, as for every action.
|
|
113
|
+
- A tool declared by two controllers is served by the one registered last, as on the other transports.
|
|
114
|
+
|
|
115
|
+
What a tool returns becomes its result:
|
|
116
|
+
|
|
117
|
+
| Returned | Result |
|
|
118
|
+
|---|---|
|
|
119
|
+
| A string | One text |
|
|
120
|
+
| An object | Its JSON as a text, and the object as structured content |
|
|
121
|
+
| `undefined` | No content |
|
|
122
|
+
| `Result.file(buffer, {contentType})` | An image or an audio for these types, otherwise an embedded resource |
|
|
123
|
+
| `Result.status(status, data)` | Its data |
|
|
124
|
+
|
|
125
|
+
- A class instance (a DTO) is given as structured content only when the tool declares `output`. A tool that declares
|
|
126
|
+
`output` and returns no object fails.
|
|
127
|
+
- The server does not check the structured content against `output`; the client does, and refuses a result that does
|
|
128
|
+
not match.
|
|
129
|
+
- An error thrown by the tool, its validation or its access control included, is given to the model as a result
|
|
130
|
+
marked as an error, whose text is `errno: message` (`E_OFFLINE: The device off is offline`). The model can then
|
|
131
|
+
correct its call.
|
|
132
|
+
|
|
133
|
+
## 3. Resources
|
|
134
|
+
|
|
135
|
+
```text
|
|
136
|
+
@MCPResource(uri, DTO?, options?)
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
- A URI without variables (`device://catalog`) is a resource of its own; a URI with variables
|
|
140
|
+
(`device://{sn}/status`) is a template, which the client fills. The URIs are never prefixed.
|
|
141
|
+
- The variables are the input of the action. They are strings: declare them as strings, or convert them with
|
|
142
|
+
`.strict(false)`, as the query of an HTTP request.
|
|
143
|
+
- The options: `resourceName` (the name listed; the name of the action by default), `title`, `mimeType`, `audience`
|
|
144
|
+
(`['user']`, `['assistant']`), `priority` (from 0 to 1), and the options of every action.
|
|
145
|
+
|
|
146
|
+
| Returned | Content |
|
|
147
|
+
|---|---|
|
|
148
|
+
| A string | A text, of the `mimeType` (`text/plain` by default) |
|
|
149
|
+
| Another value | Its JSON, of the `mimeType` (`application/json` by default) |
|
|
150
|
+
| A `Buffer`, `Result.file()` | A text or a binary, by its type |
|
|
151
|
+
|
|
152
|
+
An error thrown when a resource is read is answered as an error of the protocol: invalid params (`-32602`) when its
|
|
153
|
+
`statusCode` is a client error (400 to 499, an invalid input among them), internal error (`-32603`) otherwise. Its
|
|
154
|
+
message is `errno: message`.
|
|
155
|
+
|
|
156
|
+
## 4. Prompts
|
|
157
|
+
|
|
158
|
+
```text
|
|
159
|
+
@MCPPrompt(name, DTO?, options?)
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
- The properties of the DTO are the arguments of the prompt, listed to the client. They are strings: declare them as
|
|
163
|
+
strings, or convert them with `.strict(false)`.
|
|
164
|
+
- A string becomes one message of the user; an array, one message per item (a string, or `{role, content}`); an object
|
|
165
|
+
`{messages}` is sent as it is.
|
|
166
|
+
- The option `title`, and the options of every action. The errors are answered as for the resources.
|
|
167
|
+
|
|
168
|
+
## 5. The context
|
|
169
|
+
|
|
170
|
+
In an MCP action, `this.context` is an `MCPContext`:
|
|
171
|
+
|
|
172
|
+
| Property | What it is |
|
|
173
|
+
|---|---|
|
|
174
|
+
| `kind` | `'tool'`, `'resource'` or `'prompt'` |
|
|
175
|
+
| `name` | The name of the tool or of the prompt, prefix included; for a resource, its URI as declared |
|
|
176
|
+
| `uri` | The URI read, for a resource |
|
|
177
|
+
| `data` | The arguments, before the validation (empty for a resource, whose variables are its input) |
|
|
178
|
+
| `authInfo` | What `authenticate` returned (section 6) |
|
|
179
|
+
| `progress(progress, total?, message?)` | Reports the progress of a long call to the client |
|
|
180
|
+
|
|
181
|
+
```typescript
|
|
182
|
+
import {Controller} from 'lakutata/com/entrypoint'
|
|
183
|
+
import {MCPTool, type MCPContext} from '@lakutata/mcp'
|
|
184
|
+
|
|
185
|
+
class ImportController extends Controller {
|
|
186
|
+
@MCPTool('import', {description: 'Import the devices'})
|
|
187
|
+
public async import(): Promise<string> {
|
|
188
|
+
const context: MCPContext = this.context as MCPContext
|
|
189
|
+
for (let step: number = 1; step <= 3; step++) {
|
|
190
|
+
//The client cancelled the call
|
|
191
|
+
if (this.signal?.aborted) return 'cancelled'
|
|
192
|
+
await context.progress(step, 3, `step ${step}`)
|
|
193
|
+
}
|
|
194
|
+
return 'imported'
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- When the client cancels a call, `this.signal` is aborted.
|
|
200
|
+
- A method may serve several transports at once (`@GET` and `@MCPTool`): `this.context.type` is then `'MCP'`
|
|
201
|
+
(`MCP_CONTEXT_TYPE`) for the MCP requests.
|
|
202
|
+
- The access rules of the entrypoint apply to the MCP actions declared with `acl: true` when their
|
|
203
|
+
`supportContextTypes()` include `MCP_CONTEXT_TYPE` (see [Middlewares](Middlewares.md)).
|
|
204
|
+
- The server does not ask the client for anything: there is no sampling, elicitation or logging to the client.
|
|
205
|
+
|
|
206
|
+
**Notifications.** `MCPNotifier`, a component to inject (`@Inject(MCPNotifier)`), tells the clients that something
|
|
207
|
+
changed: `resourceUpdated(uri)`, `resourcesChanged()`, `toolsChanged()`, `promptsChanged()`. Over stdio, a resource
|
|
208
|
+
update reaches the client only if it subscribed to that URI. Over HTTP, only the clients of the revision 2026-07-28
|
|
209
|
+
that listen to the changes are notified.
|
|
210
|
+
|
|
211
|
+
## 6. Over HTTP
|
|
212
|
+
|
|
213
|
+
```typescript
|
|
214
|
+
import type {IncomingMessage} from 'node:http'
|
|
215
|
+
import {Application} from 'lakutata'
|
|
216
|
+
import {buildEntrypoints, Controller} from 'lakutata/com/entrypoint'
|
|
217
|
+
import {httpMCPEntrypoint, MCPTool, type MCPAuthInfo} from '@lakutata/mcp'
|
|
218
|
+
|
|
219
|
+
class ClockController extends Controller {
|
|
220
|
+
@MCPTool('now', {description: 'The current time of the server', readOnly: true})
|
|
221
|
+
public async now(): Promise<string> {
|
|
222
|
+
return new Date().toISOString()
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
//Throwing answers 401
|
|
227
|
+
function verifyToken(request: IncomingMessage): MCPAuthInfo {
|
|
228
|
+
const token: string = request.headers.authorization?.replace(/^Bearer /, '') ?? ''
|
|
229
|
+
if (token !== process.env.MCP_TOKEN) throw new Error('Invalid token')
|
|
230
|
+
return {token: token, clientId: 'assistant', scopes: []}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
//The clients connect to http://127.0.0.1:3000/mcp
|
|
234
|
+
Application.run(() => ({
|
|
235
|
+
id: 'clock.app',
|
|
236
|
+
name: 'Clock',
|
|
237
|
+
components: {
|
|
238
|
+
entrypoint: buildEntrypoints({
|
|
239
|
+
controllers: [ClockController],
|
|
240
|
+
mcp: httpMCPEntrypoint({port: 3000, authenticate: verifyToken})
|
|
241
|
+
})
|
|
242
|
+
},
|
|
243
|
+
bootstrap: ['entrypoint']
|
|
244
|
+
}))
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
| Option | Default | Meaning |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| `port`, `host`, `path` | `3000`, `'127.0.0.1'`, `'/mcp'` | Where it listens |
|
|
250
|
+
| `authenticate` | | `(request) => MCPAuthInfo \| undefined`: the authentication of the client; throwing answers 401, returning `undefined` serves the request without one |
|
|
251
|
+
| `allowedHosts`, `allowedOrigins` | the local names, on a local address | The `Host` and `Origin` headers accepted (403 otherwise); `false` checks none |
|
|
252
|
+
| `legacy` | `'stateless'` | The clients of the revisions of 2025: served without sessions, or refused with `'reject'` |
|
|
253
|
+
| `maxRequestBodySize` | 4 MiB | A larger body is answered with 413 |
|
|
254
|
+
| `bus` | in memory | Where the notifications go, to share them between the instances of the application |
|
|
255
|
+
| `listen` | `true` | `false` opens no port: the `handler` is mounted on another server |
|
|
256
|
+
| `serverName`, `serverVersion`, `instructions`, `server`, `listening`, `name`, `middlewares` | | As for stdio; `server` holds the options of the `node:http` server |
|
|
257
|
+
|
|
258
|
+
- It serves the protocol over Streamable HTTP: the revision 2026-07-28, and the revisions of 2025 without sessions.
|
|
259
|
+
- On a local address, the `Host` and `Origin` headers are checked against the local names, which protects the server
|
|
260
|
+
from DNS rebinding: behind a proxy, list the names the clients use. On another address, nothing is checked unless you
|
|
261
|
+
list names.
|
|
262
|
+
- When the application stops, the calls in progress are answered, then the port closes.
|
|
263
|
+
|
|
264
|
+
**Sharing the port of the HTTP adapter.** With `listen: false`, mount the `handler` of the adapter on the Express
|
|
265
|
+
application, in its `before` hook:
|
|
266
|
+
|
|
267
|
+
```typescript
|
|
268
|
+
import {buildEntrypoints} from 'lakutata/com/entrypoint'
|
|
269
|
+
import {expressHTTPEntrypoint} from 'lakutata/adapter/express'
|
|
270
|
+
import {httpMCPEntrypoint} from '@lakutata/mcp'
|
|
271
|
+
|
|
272
|
+
//The names the clients connect with (http://localhost:8080/mcp)
|
|
273
|
+
const mcp = httpMCPEntrypoint({listen: false, allowedHosts: ['localhost', '127.0.0.1', 'mcp.example.com']})
|
|
274
|
+
|
|
275
|
+
export const entrypoint = buildEntrypoints({
|
|
276
|
+
controllers: [],
|
|
277
|
+
http: expressHTTPEntrypoint({port: 8080, before: (app): void => void app.all('/mcp', mcp.handler)}),
|
|
278
|
+
mcp: mcp
|
|
279
|
+
})
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
A mounted handler checks the `Host` and `Origin` headers only against the names given, and answers 503 before the
|
|
283
|
+
adapter starts and once it stops.
|
|
284
|
+
|
|
285
|
+
## 7. Errors
|
|
286
|
+
|
|
287
|
+
| Situation | The client receives |
|
|
288
|
+
|---|---|
|
|
289
|
+
| A tool fails: an exception, an invalid argument, a refused access | A result marked as an error: `errno: message` |
|
|
290
|
+
| A resource or a prompt fails with a client error (400 to 499) | The protocol error `-32602`, its message `errno: message`, its data `{errno}` |
|
|
291
|
+
| A resource or a prompt fails otherwise | The protocol error `-32603` |
|
|
292
|
+
| An unknown tool, prompt or resource | The protocol error `-32602` (`Tool nope not found`) |
|
|
293
|
+
| An invalid name of a tool or a prompt, a resource URI without scheme | A `TypeError` when the class is declared |
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
# Lakutata Middlewares and Access Control
|
|
2
|
+
|
|
3
|
+
A middleware runs around the actions: it sees each request before the action, and its result after. The access control
|
|
4
|
+
decides whether a request may reach its action. Both work the same way for every transport.
|
|
5
|
+
|
|
6
|
+
## 1. Middlewares
|
|
7
|
+
|
|
8
|
+
A middleware is a function `(invocation, next) => Promise<unknown>`. It calls `next()` to go on with the request, and
|
|
9
|
+
returns what the request returns:
|
|
10
|
+
|
|
11
|
+
```typescript
|
|
12
|
+
import {type ActionInvocation, Controller, type Next} from 'lakutata/com/entrypoint'
|
|
13
|
+
import {GET, UseMiddlewares} from 'lakutata/decorator/ctrl'
|
|
14
|
+
|
|
15
|
+
async function timing(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
16
|
+
const started: number = Date.now()
|
|
17
|
+
try {
|
|
18
|
+
return await next()
|
|
19
|
+
} finally {
|
|
20
|
+
console.log(`${invocation.action.name} took ${Date.now() - started} ms`)
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
@UseMiddlewares(timing)
|
|
25
|
+
class HealthController extends Controller {
|
|
26
|
+
@GET('/health')
|
|
27
|
+
public async health(): Promise<string> {
|
|
28
|
+
return 'ok'
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
A middleware can also be a class that extends `Middleware` and implements `handle(invocation, next)`. The class is
|
|
34
|
+
created for each request, in the scope of the request, so it can inject other objects:
|
|
35
|
+
|
|
36
|
+
```typescript
|
|
37
|
+
import {Component} from 'lakutata'
|
|
38
|
+
import {type ActionInvocation, Middleware, type Next} from 'lakutata/com/entrypoint'
|
|
39
|
+
import {Inject} from 'lakutata/decorator/di'
|
|
40
|
+
|
|
41
|
+
class Metrics extends Component {
|
|
42
|
+
public record(name: string, milliseconds: number): void {
|
|
43
|
+
console.log(name, milliseconds)
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class Timing extends Middleware {
|
|
48
|
+
//The component declared as metrics in the components of the application
|
|
49
|
+
@Inject('metrics')
|
|
50
|
+
protected readonly metrics: Metrics
|
|
51
|
+
|
|
52
|
+
public async handle(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
53
|
+
const started: number = Date.now()
|
|
54
|
+
try {
|
|
55
|
+
return await next()
|
|
56
|
+
} finally {
|
|
57
|
+
this.metrics.record(invocation.action.name, Date.now() - started)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`invocation` describes the request:
|
|
64
|
+
|
|
65
|
+
| Property | Meaning |
|
|
66
|
+
|---|---|
|
|
67
|
+
| `context` | The context of the request (`this.context` in the action). Its `data`, the input before validation, can be changed |
|
|
68
|
+
| `action` | The action: its `name`, `description`, `groups`, its address (`route`, `command`, `pattern`) |
|
|
69
|
+
| `container` | The container of the request's scope, to get the objects of the request |
|
|
70
|
+
| `signal` | The `AbortSignal` of the request |
|
|
71
|
+
|
|
72
|
+
- A middleware that returns without calling `next()` answers the request itself: the action does not run. A cache
|
|
73
|
+
can work this way.
|
|
74
|
+
- A middleware that throws fails the request with its error, as an action that throws.
|
|
75
|
+
- `next()` can be called only once: a second call throws a `MiddlewareNextCalledException`.
|
|
76
|
+
- The middlewares run for every transport, the scheduled jobs included. `invocation.context.type` tells them apart.
|
|
77
|
+
|
|
78
|
+
## 2. Where to declare them
|
|
79
|
+
|
|
80
|
+
| Where | Applies to |
|
|
81
|
+
|---|---|
|
|
82
|
+
| `buildEntrypoints({middlewares})` | Every action |
|
|
83
|
+
| The `middlewares` option of an adapter | The actions reached through that adapter |
|
|
84
|
+
| `@UseMiddlewares(...)` on a controller class | The actions of the class and of its subclasses |
|
|
85
|
+
| The `middlewares` action option | That action |
|
|
86
|
+
| `@UseMiddlewares(...)` on a method | That method |
|
|
87
|
+
|
|
88
|
+
They run in the order of this table, from the first row (the outermost) to the last: entrypoint, adapter, controller
|
|
89
|
+
class, action option, method. On a controller, the middlewares of the parent classes run before the ones of the
|
|
90
|
+
subclass. After the last middleware come the validation of the input, the access control, then the action.
|
|
91
|
+
|
|
92
|
+
## 3. Passing data to the action
|
|
93
|
+
|
|
94
|
+
A middleware often finds something the action needs, such as the user who sent the request. The input is the wrong
|
|
95
|
+
place for it: the DTO validates the input after the middlewares, and removes the keys it does not declare. Use a
|
|
96
|
+
provider of the request instead. A `@Scoped` provider has one instance per request: the middleware fills it, and the
|
|
97
|
+
action injects the same instance.
|
|
98
|
+
|
|
99
|
+
```typescript
|
|
100
|
+
import {Exception, Provider} from 'lakutata'
|
|
101
|
+
import {type ActionInvocation, Controller, type HTTPContext, Middleware, type Next} from 'lakutata/com/entrypoint'
|
|
102
|
+
import {GET, UseMiddlewares} from 'lakutata/decorator/ctrl'
|
|
103
|
+
import {Autoload, Inject, Scoped} from 'lakutata/decorator/di'
|
|
104
|
+
|
|
105
|
+
class UnauthorizedException extends Exception {
|
|
106
|
+
public errno: string = 'E_UNAUTHORIZED'
|
|
107
|
+
|
|
108
|
+
public statusCode: number = 401
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
//One instance per request, registered the first time it is used
|
|
112
|
+
@Autoload()
|
|
113
|
+
@Scoped()
|
|
114
|
+
export class CurrentUser extends Provider {
|
|
115
|
+
public name: string
|
|
116
|
+
|
|
117
|
+
public role: string
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class Authenticate extends Middleware {
|
|
121
|
+
public async handle(invocation: ActionInvocation, next: Next): Promise<unknown> {
|
|
122
|
+
const token: string | undefined = (invocation.context as HTTPContext).request.headers.authorization
|
|
123
|
+
if (!token) throw new UnauthorizedException('Not logged in')
|
|
124
|
+
const user: CurrentUser = await invocation.container.get(CurrentUser)
|
|
125
|
+
//Look the token up in a real application
|
|
126
|
+
user.name = token
|
|
127
|
+
user.role = token === 'ada' ? 'admin' : 'user'
|
|
128
|
+
return await next()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@UseMiddlewares(Authenticate)
|
|
133
|
+
class ProfileController extends Controller {
|
|
134
|
+
@Inject(CurrentUser)
|
|
135
|
+
protected readonly user: CurrentUser
|
|
136
|
+
|
|
137
|
+
@GET('/me')
|
|
138
|
+
public async me(): Promise<{ name: string, role: string }> {
|
|
139
|
+
return {name: this.user.name, role: this.user.role}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
```text
|
|
145
|
+
$ curl localhost:8080/me -H 'Authorization: ada'
|
|
146
|
+
{"name":"ada","role":"admin"}
|
|
147
|
+
$ curl localhost:8080/me
|
|
148
|
+
{"errno":"E_UNAUTHORIZED","errMsg":"Not logged in"}
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
## 4. Access control
|
|
152
|
+
|
|
153
|
+
An action declared with `acl: true` is checked by the access rules of the entrypoint. A rule is a class that extends
|
|
154
|
+
`AccessControlRule` and is listed in `buildEntrypoints({rules})`:
|
|
155
|
+
|
|
156
|
+
```typescript
|
|
157
|
+
import {Provider} from 'lakutata'
|
|
158
|
+
import {AccessControlRule, ContextType, Controller, type HTTPContext} from 'lakutata/com/entrypoint'
|
|
159
|
+
import {DELETE, GET} from 'lakutata/decorator/ctrl'
|
|
160
|
+
import {Autoload, Inject, Scoped} from 'lakutata/decorator/di'
|
|
161
|
+
|
|
162
|
+
//The provider of section 3, filled by the middleware Authenticate
|
|
163
|
+
@Autoload()
|
|
164
|
+
@Scoped()
|
|
165
|
+
class CurrentUser extends Provider {
|
|
166
|
+
public name: string
|
|
167
|
+
|
|
168
|
+
public role: string
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
//The actions of the "admin" group are for the administrators
|
|
172
|
+
export class AdminRule extends AccessControlRule<HTTPContext> {
|
|
173
|
+
@Inject(CurrentUser)
|
|
174
|
+
protected readonly user: CurrentUser
|
|
175
|
+
|
|
176
|
+
public get supportContextTypes(): ContextType[] {
|
|
177
|
+
return [ContextType.HTTP]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public async rule(): Promise<boolean> {
|
|
181
|
+
if (!this.targetAction.groups.includes('admin')) return true
|
|
182
|
+
return this.user.role === 'admin'
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export class StatsController extends Controller {
|
|
187
|
+
@GET('/admin/stats', {acl: true, groups: ['admin']})
|
|
188
|
+
public async stats(): Promise<string> {
|
|
189
|
+
return 'stats'
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
//A rule of this action only; no acl needed
|
|
193
|
+
@DELETE('/drafts/:id', {rule: async (context): Promise<boolean> => (context as HTTPContext).request.headers['x-owner'] === 'yes'})
|
|
194
|
+
public async removeDraft(): Promise<void> {
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
//buildEntrypoints({controllers: [StatsController], rules: [AdminRule], httpActionGroups: {admin: 'Administration'}})
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
- A rule is created for each request, in the scope of the request: it can inject the objects of the request, such as
|
|
202
|
+
the `CurrentUser` filled by a middleware.
|
|
203
|
+
- In a rule, `this.context` is the context of the request, `this.input` the validated input, `this.targetAction` the
|
|
204
|
+
action (its `name`, its `groups`), and `this.actions` all the actions of the transport.
|
|
205
|
+
- `supportContextTypes` lists the transports the rule checks; it is skipped for the others.
|
|
206
|
+
- The rules run in the order of the `rules` option, then the `rule` option of the action. The first one that resolves
|
|
207
|
+
`false` denies the request with an `AccessDeniedException` (errno `E_ACCESS_DENY`, status 403 over HTTP); one that
|
|
208
|
+
throws denies it with its own error (such as a 401).
|
|
209
|
+
- The `rule` option of an action works on its own: without `acl`, only that rule is checked.
|
|
210
|
+
- An action with neither `acl` nor `rule` is not checked. An `acl: true` action is allowed when no rule denies it.
|
|
211
|
+
|
|
212
|
+
The access control runs after the middlewares and the validation of the input, just before the action. To change how
|
|
213
|
+
it decides for the whole application, see [Entrypoint](Entrypoint.md), section 7.
|