@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.
@@ -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) return wrapResponse(validationError(result.issues))
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
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
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) return wrapResponse(validationError(result.issues))
2106
- ctx.query = result.value
2107
- if (entry.hasDecorations) Object.assign(ctx, entry.decorations)
2108
- const handlerOutput = entry.handler(ctx)
2109
- return handlerOutput instanceof Promise
2110
- ? handlerOutput.then(
2111
- (value) => finalize(value, responseSet(ctx)),
2112
- (err) => this.handleLifecycleError(entry, err, ctx, finalize, wrapResponse),
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
- : finalize(handlerOutput, responseSet(ctx))
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
  /**