@nifrajs/core 1.2.2 → 1.3.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/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/server/context.d.ts +22 -1
- package/dist/server/context.d.ts.map +1 -1
- package/dist/server/contract.d.ts +13 -0
- package/dist/server/contract.d.ts.map +1 -1
- package/dist/server/contract.js.map +1 -1
- package/dist/server/registry.d.ts +11 -0
- package/dist/server/registry.d.ts.map +1 -1
- package/dist/server/server.d.ts +103 -0
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +141 -14
- package/dist/server/server.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +6 -1
- package/src/server/context.ts +26 -1
- package/src/server/contract.ts +25 -0
- package/src/server/registry.ts +12 -0
- package/src/server/server.ts +309 -15
package/src/server/server.ts
CHANGED
|
@@ -250,6 +250,32 @@ export interface ServerOptions {
|
|
|
250
250
|
readonly gracefulSignals?: boolean
|
|
251
251
|
/** Structured logger for framework events (redacts secrets). Default: JSON to stderr. */
|
|
252
252
|
readonly logger?: Logger
|
|
253
|
+
/**
|
|
254
|
+
* App-wide fallback fired when a route **without its own `onValidationError`** fails body/query
|
|
255
|
+
* validation. Same contract as the per-route hook (`(issues, ctx, kind) => Response | repaired-value |
|
|
256
|
+
* undefined`): a route's own hook takes precedence, and a route can fall through to the plain `422` by
|
|
257
|
+
* returning `undefined`. Use it for one app-wide error envelope (like tRPC's `errorFormatter` /
|
|
258
|
+
* Fastify's `setErrorHandler`) instead of repeating a formatter per route.
|
|
259
|
+
*/
|
|
260
|
+
readonly onValidationError?: RouteSchema["onValidationError"]
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* MCP tool safety hints, surfaced in `tools/list`, that tell an agent how risky a `.tool()` call is — so it
|
|
265
|
+
* can decide whether to auto-invoke or confirm first. All optional; an omitted hint means "unknown". Mirrors
|
|
266
|
+
* the MCP spec's tool `annotations`.
|
|
267
|
+
*/
|
|
268
|
+
export interface ToolAnnotations {
|
|
269
|
+
/** Human-readable display title for the tool, distinct from the machine `name`. */
|
|
270
|
+
readonly title?: string
|
|
271
|
+
/** The tool does not modify its environment (a pure read). */
|
|
272
|
+
readonly readOnlyHint?: boolean
|
|
273
|
+
/** The tool may perform destructive updates — only meaningful when `readOnlyHint` is not `true`. */
|
|
274
|
+
readonly destructiveHint?: boolean
|
|
275
|
+
/** Repeated calls with the same arguments have no additional effect beyond the first. */
|
|
276
|
+
readonly idempotentHint?: boolean
|
|
277
|
+
/** The tool interacts with external entities (an "open world" beyond this server). */
|
|
278
|
+
readonly openWorldHint?: boolean
|
|
253
279
|
}
|
|
254
280
|
|
|
255
281
|
/**
|
|
@@ -261,6 +287,41 @@ export interface RouteDescriptor {
|
|
|
261
287
|
readonly method: Method
|
|
262
288
|
readonly path: string
|
|
263
289
|
readonly schema: RouteSchema | undefined
|
|
290
|
+
readonly tool?: {
|
|
291
|
+
readonly name: string
|
|
292
|
+
readonly description: string
|
|
293
|
+
readonly annotations?: ToolAnnotations
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** A message in an MCP prompt's rendered output (see {@link Server.prompt}). */
|
|
298
|
+
export interface PromptMessage {
|
|
299
|
+
readonly role: "user" | "assistant"
|
|
300
|
+
readonly content: { readonly type: "text"; readonly text: string }
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** One declared argument of an MCP prompt, surfaced in `prompts/list`. */
|
|
304
|
+
export interface PromptArgument {
|
|
305
|
+
readonly name: string
|
|
306
|
+
readonly description?: string
|
|
307
|
+
readonly required?: boolean
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** An app-declared MCP resource — read-only data an agent can fetch through `nifra mcp`. */
|
|
311
|
+
export interface McpResourceDescriptor {
|
|
312
|
+
readonly uri: string
|
|
313
|
+
readonly name: string
|
|
314
|
+
readonly description?: string
|
|
315
|
+
readonly mimeType?: string
|
|
316
|
+
readonly read: () => MaybePromise<string | { readonly text: string; readonly mimeType?: string }>
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** An app-declared MCP prompt — a reusable prompt template an agent can fetch through `nifra mcp`. */
|
|
320
|
+
export interface McpPromptDescriptor {
|
|
321
|
+
readonly name: string
|
|
322
|
+
readonly description: string
|
|
323
|
+
readonly arguments?: readonly PromptArgument[]
|
|
324
|
+
readonly handler: (args: Record<string, string>) => MaybePromise<readonly PromptMessage[]>
|
|
264
325
|
}
|
|
265
326
|
|
|
266
327
|
/**
|
|
@@ -1039,6 +1100,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1039
1100
|
private readonly requestTimeoutMs: number
|
|
1040
1101
|
private readonly gracefulSignals: boolean
|
|
1041
1102
|
private readonly logger: Logger
|
|
1103
|
+
/** App-wide validation-error fallback; a route's own `schema.onValidationError` takes precedence. */
|
|
1104
|
+
private readonly defaultOnValidationError?: RouteSchema["onValidationError"]
|
|
1042
1105
|
private bunServer: RunningServer | undefined
|
|
1043
1106
|
private readonly derives: RawDerive[]
|
|
1044
1107
|
private readonly decorations: Record<string, unknown>
|
|
@@ -1051,6 +1114,9 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1051
1114
|
private readonly responseRequests: WeakMap<Request, Request>
|
|
1052
1115
|
/** Names of plugins/middleware already applied via `use` — for idempotent dedupe. */
|
|
1053
1116
|
private readonly appliedPlugins: Set<string>
|
|
1117
|
+
/** App-declared MCP resources / prompts (via {@link resource} / {@link prompt}), read by `nifra mcp`. */
|
|
1118
|
+
private readonly mcpResourceList: McpResourceDescriptor[]
|
|
1119
|
+
private readonly mcpPromptList: McpPromptDescriptor[]
|
|
1054
1120
|
|
|
1055
1121
|
constructor(options: ServerOptions = {}) {
|
|
1056
1122
|
this.router = new Router<RouteEntry>()
|
|
@@ -1063,6 +1129,7 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1063
1129
|
this.requestTimeoutMs = options.requestTimeoutMs ?? 0
|
|
1064
1130
|
this.gracefulSignals = options.gracefulSignals ?? false
|
|
1065
1131
|
this.logger = options.logger ?? jsonLogger()
|
|
1132
|
+
this.defaultOnValidationError = options.onValidationError
|
|
1066
1133
|
this.bunServer = undefined
|
|
1067
1134
|
this.derives = []
|
|
1068
1135
|
this.decorations = {}
|
|
@@ -1074,6 +1141,8 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1074
1141
|
this.onResponseHooks = []
|
|
1075
1142
|
this.responseRequests = new WeakMap()
|
|
1076
1143
|
this.appliedPlugins = new Set()
|
|
1144
|
+
this.mcpResourceList = []
|
|
1145
|
+
this.mcpPromptList = []
|
|
1077
1146
|
}
|
|
1078
1147
|
|
|
1079
1148
|
/** Add a per-request, computed context extension for subsequent routes. */
|
|
@@ -1260,6 +1329,118 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
1260
1329
|
return this.route("DELETE", path, schemaOrHandler, handler)
|
|
1261
1330
|
}
|
|
1262
1331
|
|
|
1332
|
+
tool<
|
|
1333
|
+
Name extends string,
|
|
1334
|
+
S extends {
|
|
1335
|
+
description: string
|
|
1336
|
+
input: StandardSchemaV1
|
|
1337
|
+
output?: StandardSchemaV1
|
|
1338
|
+
annotations?: ToolAnnotations
|
|
1339
|
+
},
|
|
1340
|
+
H extends (
|
|
1341
|
+
input: InferOutput<S["input"]>,
|
|
1342
|
+
ctx: Context & Ctx,
|
|
1343
|
+
) => MaybePromise<S["output"] extends StandardSchemaV1 ? InferOutput<S["output"]> : unknown>,
|
|
1344
|
+
>(
|
|
1345
|
+
name: Name,
|
|
1346
|
+
config: S,
|
|
1347
|
+
handler: H,
|
|
1348
|
+
): Server<
|
|
1349
|
+
AddRoute<
|
|
1350
|
+
R,
|
|
1351
|
+
"POST",
|
|
1352
|
+
`/_nifra/tool/${Name}`,
|
|
1353
|
+
RouteInfoFor<
|
|
1354
|
+
`/_nifra/tool/${Name}`,
|
|
1355
|
+
S["output"] extends StandardSchemaV1
|
|
1356
|
+
? { body: S["input"]; response: S["output"] }
|
|
1357
|
+
: { body: S["input"] },
|
|
1358
|
+
OutputOf<H>
|
|
1359
|
+
>
|
|
1360
|
+
>,
|
|
1361
|
+
Ctx
|
|
1362
|
+
>
|
|
1363
|
+
tool(
|
|
1364
|
+
name: string,
|
|
1365
|
+
config: {
|
|
1366
|
+
description: string
|
|
1367
|
+
input: StandardSchemaV1
|
|
1368
|
+
output?: StandardSchemaV1
|
|
1369
|
+
annotations?: ToolAnnotations
|
|
1370
|
+
},
|
|
1371
|
+
handler: (input: unknown, ctx: Context & Ctx) => unknown,
|
|
1372
|
+
): Server<Registry, Ctx> {
|
|
1373
|
+
const path = `/_nifra/tool/${name}`
|
|
1374
|
+
const routeSchema: RouteSchema =
|
|
1375
|
+
config.output !== undefined
|
|
1376
|
+
? { body: config.input, response: config.output }
|
|
1377
|
+
: { body: config.input }
|
|
1378
|
+
// register's handler is the erased `(context: never) => unknown`; the tool's typed input/ctx come from
|
|
1379
|
+
// the overload above, so bridge with a single cast rather than `any`.
|
|
1380
|
+
const run = (c: Context & Ctx): unknown => handler(c.body, c)
|
|
1381
|
+
this.register("POST", path, routeSchema, run as (context: never) => unknown)
|
|
1382
|
+
// Tag the just-registered descriptor as an MCP tool. `tool` is readonly on RouteDescriptor (an
|
|
1383
|
+
// introspection field), so write it through a narrow mutable view — not `any`.
|
|
1384
|
+
const lastRoute = this.routeList[this.routeList.length - 1]
|
|
1385
|
+
if (lastRoute) {
|
|
1386
|
+
;(lastRoute as { tool?: RouteDescriptor["tool"] }).tool = {
|
|
1387
|
+
name,
|
|
1388
|
+
description: config.description,
|
|
1389
|
+
...(config.annotations !== undefined ? { annotations: config.annotations } : {}),
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
return this as unknown as Server<Registry, Ctx>
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
/**
|
|
1396
|
+
* Declare an MCP **resource** — read-only data an agent can fetch through `nifra mcp` (app config, a
|
|
1397
|
+
* generated document, …). `read` runs in the app process, so capture whatever app state it needs in the
|
|
1398
|
+
* closure. `uri` is the MCP resource identifier (e.g. `"myapp://config"`). The sibling of {@link tool}
|
|
1399
|
+
* for the resource half of MCP.
|
|
1400
|
+
*/
|
|
1401
|
+
resource(
|
|
1402
|
+
uri: string,
|
|
1403
|
+
config: { readonly name: string; readonly description?: string; readonly mimeType?: string },
|
|
1404
|
+
read: McpResourceDescriptor["read"],
|
|
1405
|
+
): Server<R, Ctx> {
|
|
1406
|
+
this.mcpResourceList.push({
|
|
1407
|
+
uri,
|
|
1408
|
+
name: config.name,
|
|
1409
|
+
...(config.description !== undefined ? { description: config.description } : {}),
|
|
1410
|
+
...(config.mimeType !== undefined ? { mimeType: config.mimeType } : {}),
|
|
1411
|
+
read,
|
|
1412
|
+
})
|
|
1413
|
+
return this
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* Declare an MCP **prompt** — a reusable prompt template an agent can fetch through `nifra mcp`.
|
|
1418
|
+
* `handler` receives the caller's arguments and returns the rendered messages.
|
|
1419
|
+
*/
|
|
1420
|
+
prompt(
|
|
1421
|
+
name: string,
|
|
1422
|
+
config: { readonly description: string; readonly arguments?: readonly PromptArgument[] },
|
|
1423
|
+
handler: McpPromptDescriptor["handler"],
|
|
1424
|
+
): Server<R, Ctx> {
|
|
1425
|
+
this.mcpPromptList.push({
|
|
1426
|
+
name,
|
|
1427
|
+
description: config.description,
|
|
1428
|
+
...(config.arguments !== undefined ? { arguments: config.arguments } : {}),
|
|
1429
|
+
handler,
|
|
1430
|
+
})
|
|
1431
|
+
return this
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** The MCP resources declared via {@link resource} — enumerated by `nifra mcp`. */
|
|
1435
|
+
mcpResources(): readonly McpResourceDescriptor[] {
|
|
1436
|
+
return this.mcpResourceList
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** The MCP prompts declared via {@link prompt} — enumerated by `nifra mcp`. */
|
|
1440
|
+
mcpPrompts(): readonly McpPromptDescriptor[] {
|
|
1441
|
+
return this.mcpPromptList
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1263
1444
|
/**
|
|
1264
1445
|
* Register a **WebSocket** route. The connection upgrades on a `GET` to `path` carrying
|
|
1265
1446
|
* `Upgrade: websocket`; the optional `handler.upgrade(c)` runs in the request context first and may
|
|
@@ -2042,6 +2223,74 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2042
2223
|
}
|
|
2043
2224
|
}
|
|
2044
2225
|
|
|
2226
|
+
private executeHandler<T>(
|
|
2227
|
+
entry: RouteEntry,
|
|
2228
|
+
ctx: RawContext,
|
|
2229
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2230
|
+
wrapResponse?: (response: Response) => T,
|
|
2231
|
+
): MaybePromise<T> {
|
|
2232
|
+
if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
|
|
2233
|
+
const handlerOutput = entry.handler(ctx)
|
|
2234
|
+
if (handlerOutput instanceof Promise) {
|
|
2235
|
+
return handlerOutput.then(
|
|
2236
|
+
(value) => finalize(value, responseSet(ctx)),
|
|
2237
|
+
(err) => {
|
|
2238
|
+
if (wrapResponse) {
|
|
2239
|
+
return this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse)
|
|
2240
|
+
}
|
|
2241
|
+
throw err
|
|
2242
|
+
},
|
|
2243
|
+
)
|
|
2244
|
+
}
|
|
2245
|
+
return finalize(handlerOutput, responseSet(ctx))
|
|
2246
|
+
}
|
|
2247
|
+
|
|
2248
|
+
private handleValidationErrorRecovery<T>(
|
|
2249
|
+
entry: RouteEntry,
|
|
2250
|
+
recovery: unknown,
|
|
2251
|
+
ctx: RawContext,
|
|
2252
|
+
finalize: (result: unknown, set: CtxSet) => T,
|
|
2253
|
+
wrapResponse: (response: Response) => T,
|
|
2254
|
+
originalIssues: ReadonlyArray<StandardIssue>,
|
|
2255
|
+
kind: "body" | "query",
|
|
2256
|
+
): MaybePromise<T> {
|
|
2257
|
+
if (recovery !== undefined) {
|
|
2258
|
+
if (recovery instanceof Response) {
|
|
2259
|
+
return wrapResponse(recovery)
|
|
2260
|
+
}
|
|
2261
|
+
if (kind === "body" && entry.schema?.body) {
|
|
2262
|
+
const validation = entry.schema.body["~standard"].validate(recovery)
|
|
2263
|
+
if (validation instanceof Promise) {
|
|
2264
|
+
return validation.then((settled) => {
|
|
2265
|
+
if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
|
|
2266
|
+
ctx.body = settled.value
|
|
2267
|
+
return this.executeHandler(entry, ctx, finalize)
|
|
2268
|
+
})
|
|
2269
|
+
}
|
|
2270
|
+
if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
|
|
2271
|
+
ctx.body = validation.value
|
|
2272
|
+
return this.executeHandler(entry, ctx, finalize)
|
|
2273
|
+
}
|
|
2274
|
+
if (kind === "query" && entry.schema?.query) {
|
|
2275
|
+
const validation = entry.schema.query["~standard"].validate(recovery)
|
|
2276
|
+
if (validation instanceof Promise) {
|
|
2277
|
+
return validation.then(
|
|
2278
|
+
(settled) => {
|
|
2279
|
+
if (settled.issues !== undefined) return wrapResponse(validationError(settled.issues))
|
|
2280
|
+
ctx.query = settled.value
|
|
2281
|
+
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
2282
|
+
},
|
|
2283
|
+
(err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
|
|
2284
|
+
)
|
|
2285
|
+
}
|
|
2286
|
+
if (validation.issues !== undefined) return wrapResponse(validationError(validation.issues))
|
|
2287
|
+
ctx.query = validation.value
|
|
2288
|
+
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
2289
|
+
}
|
|
2290
|
+
}
|
|
2291
|
+
return wrapResponse(validationError(originalIssues))
|
|
2292
|
+
}
|
|
2293
|
+
|
|
2045
2294
|
private applyBodyValidation<T>(
|
|
2046
2295
|
entry: RouteEntry,
|
|
2047
2296
|
result: StandardResult<unknown>,
|
|
@@ -2049,13 +2298,37 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2049
2298
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2050
2299
|
wrapResponse: (response: Response) => T,
|
|
2051
2300
|
): MaybePromise<T> {
|
|
2052
|
-
if (result.issues !== undefined)
|
|
2301
|
+
if (result.issues !== undefined) {
|
|
2302
|
+
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
2303
|
+
if (hook) {
|
|
2304
|
+
const recovery = hook(result.issues, ctx as unknown as Context, "body")
|
|
2305
|
+
if (recovery instanceof Promise) {
|
|
2306
|
+
return recovery.then((rec) =>
|
|
2307
|
+
this.handleValidationErrorRecovery(
|
|
2308
|
+
entry,
|
|
2309
|
+
rec,
|
|
2310
|
+
ctx,
|
|
2311
|
+
finalize,
|
|
2312
|
+
wrapResponse,
|
|
2313
|
+
result.issues!,
|
|
2314
|
+
"body",
|
|
2315
|
+
),
|
|
2316
|
+
)
|
|
2317
|
+
}
|
|
2318
|
+
return this.handleValidationErrorRecovery(
|
|
2319
|
+
entry,
|
|
2320
|
+
recovery,
|
|
2321
|
+
ctx,
|
|
2322
|
+
finalize,
|
|
2323
|
+
wrapResponse,
|
|
2324
|
+
result.issues,
|
|
2325
|
+
"body",
|
|
2326
|
+
)
|
|
2327
|
+
}
|
|
2328
|
+
return wrapResponse(validationError(result.issues))
|
|
2329
|
+
}
|
|
2053
2330
|
ctx.body = result.value
|
|
2054
|
-
|
|
2055
|
-
const handlerOutput = entry.handler(ctx)
|
|
2056
|
-
return handlerOutput instanceof Promise
|
|
2057
|
-
? handlerOutput.then((value) => finalize(value, responseSet(ctx)))
|
|
2058
|
-
: finalize(handlerOutput, responseSet(ctx))
|
|
2331
|
+
return this.executeHandler(entry, ctx, finalize)
|
|
2059
2332
|
}
|
|
2060
2333
|
|
|
2061
2334
|
private runQueryOnly<T>(
|
|
@@ -2102,16 +2375,37 @@ export class Server<R extends Registry = EmptyRegistry, Ctx = EmptyContext> {
|
|
|
2102
2375
|
finalize: (result: unknown, set: CtxSet) => T,
|
|
2103
2376
|
wrapResponse: (response: Response) => T,
|
|
2104
2377
|
): MaybePromise<T> {
|
|
2105
|
-
if (result.issues !== undefined)
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2378
|
+
if (result.issues !== undefined) {
|
|
2379
|
+
const hook = entry.schema?.onValidationError ?? this.defaultOnValidationError
|
|
2380
|
+
if (hook) {
|
|
2381
|
+
const recovery = hook(result.issues, ctx as unknown as Context, "query")
|
|
2382
|
+
if (recovery instanceof Promise) {
|
|
2383
|
+
return recovery.then((rec) =>
|
|
2384
|
+
this.handleValidationErrorRecovery(
|
|
2385
|
+
entry,
|
|
2386
|
+
rec,
|
|
2387
|
+
ctx,
|
|
2388
|
+
finalize,
|
|
2389
|
+
wrapResponse,
|
|
2390
|
+
result.issues!,
|
|
2391
|
+
"query",
|
|
2392
|
+
),
|
|
2393
|
+
)
|
|
2394
|
+
}
|
|
2395
|
+
return this.handleValidationErrorRecovery(
|
|
2396
|
+
entry,
|
|
2397
|
+
recovery,
|
|
2398
|
+
ctx,
|
|
2399
|
+
finalize,
|
|
2400
|
+
wrapResponse,
|
|
2401
|
+
result.issues,
|
|
2402
|
+
"query",
|
|
2113
2403
|
)
|
|
2114
|
-
|
|
2404
|
+
}
|
|
2405
|
+
return wrapResponse(validationError(result.issues))
|
|
2406
|
+
}
|
|
2407
|
+
ctx.query = result.value
|
|
2408
|
+
return this.executeHandler(entry, ctx, finalize, wrapResponse)
|
|
2115
2409
|
}
|
|
2116
2410
|
|
|
2117
2411
|
/**
|