TypeScriptTools

Middleware, Caching, and Errors

Wrap tools with middleware, cache and rate-limit them, inject services, and handle errors

Tools run inside NitroStack's request pipeline, so the same decorators that add logging, caching, and rate limits to one tool work on any other.

The request pipeline

Attach pipeline classes to a tool with decorators. They run in a fixed order: guards, middleware, interceptors, pipes, then the handler.

Guards

Guards control access to tools based on authentication or authorization:

import { UseGuards } from '@nitrostack/core';
import { JWTGuard } from './guards/jwt.guard.js';
import { RoleGuard } from './guards/role.guard.js';

// Single guard
@Tool({ name: 'get_profile' })
@UseGuards(JWTGuard)
async getProfile(input: {}, ctx: ExecutionContext) {
  return this.userService.findById(ctx.auth!.subject);
}

// Multiple guards (all must pass)
@Tool({ name: 'delete_user' })
@UseGuards(JWTGuard, RoleGuard('admin'))
async deleteUser(input: { userId: string }, ctx: ExecutionContext) {
  return this.userService.delete(input.userId);
}

Middleware

Middleware executes before and after the tool handler:

import { UseMiddleware } from '@nitrostack/core';
import { LoggingMiddleware } from './middleware/logging.middleware.js';
import { TimingMiddleware } from './middleware/timing.middleware.js';

@Tool({ name: 'expensive_operation' })
@UseMiddleware(LoggingMiddleware, TimingMiddleware)
async expensiveOperation(input: OperationInput, ctx: ExecutionContext) {
  // Middleware executes in order: Logging -> Timing -> Handler -> Timing -> Logging
  return this.computeService.process(input);
}

Interceptors

Interceptors transform responses or add cross-cutting behavior:

import { UseInterceptors } from '@nitrostack/core';
import { TransformInterceptor } from './interceptors/transform.interceptor.js';

@Tool({ name: 'get_data' })
@UseInterceptors(TransformInterceptor)
async getData(input: { id: string }, ctx: ExecutionContext) {
  return { value: 42 };
  // Interceptor transforms to: { success: true, data: { value: 42 }, timestamp: '...' }
}

Pipes

Pipes validate and transform input before handler execution:

import { UsePipes } from '@nitrostack/core';
import { TrimPipe } from './pipes/trim.pipe.js';
import { ValidationPipe } from './pipes/validation.pipe.js';

@Tool({ name: 'search' })
@UsePipes(TrimPipe, ValidationPipe)
async search(input: { query: string }, ctx: ExecutionContext) {
  // Input strings are trimmed and validated
  return this.searchService.query(input.query);
}

Exception Filters

Exception filters handle errors and transform error responses:

import { UseFilters } from '@nitrostack/core';
import { HttpExceptionFilter } from './filters/http-exception.filter.js';

@Tool({ name: 'risky_operation' })
@UseFilters(HttpExceptionFilter)
async riskyOperation(input: RiskyInput, ctx: ExecutionContext) {
  // Errors are caught and transformed by the filter
  return this.riskyService.execute(input);
}

Caching and rate limiting

Two decorators control how often a tool's handler actually runs: @Cache reuses a recent result, and @RateLimit caps how often a caller can invoke the tool.

Response Caching

Cache tool responses to improve performance:

import { Cache } from '@nitrostack/core';

@Tool({ name: 'get_product' })
@Cache({
  ttl: 300,  // Cache for 5 minutes
  key: (input) => `product:${input.productId}`  // Custom cache key
})
async getProduct(input: { productId: string }, ctx: ExecutionContext) {
  return this.productService.findById(input.productId);
}

// Cache with event-based invalidation
@Tool({ name: 'get_user_profile' })
@Cache({
  ttl: 600,
  key: (input) => `user:${input.userId}:profile`,
  invalidateOn: ['user.updated', 'user.deleted']
})
async getUserProfile(input: { userId: string }, ctx: ExecutionContext) {
  return this.userService.getProfile(input.userId);
}

Rate Limiting

Protect tools from abuse with rate limiting:

import { RateLimit } from '@nitrostack/core';

@Tool({ name: 'send_email' })
@RateLimit({
  requests: 10,
  window: '1m',  // 10 requests per minute
  key: (ctx) => ctx.auth?.subject || 'anonymous',  // Per-user limiting
  message: 'Email rate limit exceeded. Please wait before sending more emails.'
})
async sendEmail(input: EmailInput, ctx: ExecutionContext) {
  return this.emailService.send(input);
}

// Multiple rate limits
@Tool({ name: 'api_call' })
@RateLimit({ requests: 100, window: '1m' })   // Burst limit
@RateLimit({ requests: 1000, window: '1h' })  // Hourly limit
@RateLimit({ requests: 10000, window: '1d' }) // Daily limit
async apiCall(input: ApiInput, ctx: ExecutionContext) {
  return this.apiService.call(input);
}

Dependency injection

Inject services into tool classes:

import { Injectable } from '@nitrostack/core';

@Injectable()
export class ProductService {
  constructor(
    private db: DatabaseService,
    private cache: CacheService
  ) {}

  async findById(id: string): Promise<Product | null> {
    const cached = await this.cache.get(`product:${id}`);
    if (cached) return cached;

    const product = await this.db.query(
      'SELECT * FROM products WHERE id = $1',
      [id]
    );

    if (product) {
      await this.cache.set(`product:${id}`, product, 300);
    }

    return product;
  }
}

export class ProductTools {
  constructor(private productService: ProductService) {}

  @Tool({ name: 'get_product' })
  async getProduct(input: { productId: string }, ctx: ExecutionContext) {
    const product = await this.productService.findById(input.productId);
    if (!product) {
      throw new Error(`Product not found: ${input.productId}`);
    }
    return product;
  }
}

Error handling

A tool reports failure by throwing. NitroStack turns the error into an MCP error result, after passing it through any exception filters attached to the tool.

Standard Errors

Throwing a plain Error is enough for most failures. The message is what the model reads, so make it say what went wrong.

@Tool({ name: 'get_user' })
async getUser(input: { userId: string }, ctx: ExecutionContext) {
  const user = await this.userService.findById(input.userId);

  if (!user) {
    throw new Error(`User not found: ${input.userId}`);
  }

  return user;
}

Custom Error Classes

Custom error classes carry extra fields, such as which input was invalid, that an exception filter can turn into a structured response.

export class NotFoundError extends Error {
  constructor(resource: string, id: string) {
    super(`${resource} not found: ${id}`);
    this.name = 'NotFoundError';
  }
}

export class ValidationError extends Error {
  constructor(
    message: string,
    public readonly field: string,
    public readonly value: unknown
  ) {
    super(message);
    this.name = 'ValidationError';
  }
}

@Tool({ name: 'update_user' })
async updateUser(input: UpdateUserInput, ctx: ExecutionContext) {
  const user = await this.userService.findById(input.userId);
  if (!user) {
    throw new NotFoundError('User', input.userId);
  }

  if (input.email && !this.isValidEmail(input.email)) {
    throw new ValidationError('Invalid email format', 'email', input.email);
  }

  return this.userService.update(input.userId, input);
}

Next steps

On this page