NitroStack Logo
/sdk
/typescript
/interceptors

Interceptors Guide

Overview

Interceptors bind additional logic before and after handler execution. Unlike middleware, interceptors focus on transforming requests and responses, adding metadata, or implementing cross-cutting patterns like caching and response wrapping.

Table of Contents

Creating Interceptors

Basic Interceptor

Interceptors implement the InterceptorInterface:

Typescript
import { Interceptor, InterceptorInterface, ExecutionContext } from '@nitrostack/core';

@Interceptor()
export class ResponseWrapperInterceptor implements InterceptorInterface {
  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const startTime = Date.now();

    // Execute handler
    const result = await next();

    // Transform response
    return {
      success: true,
      data: result,
      metadata: {
        tool: context.toolName,
        requestId: context.metadata?.requestId || context.requestId,
        timestamp: new Date().toISOString(),
        duration: Date.now() - startTime
      }
    };
  }
}

Interceptor Interface

Typescript
interface InterceptorInterface {
  intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any>;
}

Parameters:

  • context: Execution context with auth, logger, and metadata
  • next: Function to call the handler

Return: Transformed response

Using Interceptors

On Individual Methods

Typescript
import { ToolDecorator as Tool, UseInterceptors, ExecutionContext } from '@nitrostack/core';
import { ResponseWrapperInterceptor } from './interceptors/response-wrapper.interceptor.js';

export class ProductTools {
  @Tool({ name: 'get_product' })
  @UseInterceptors(ResponseWrapperInterceptor)
  async getProduct(input: { productId: string }, ctx: ExecutionContext) {
    return this.productService.findById(input.productId);
    // Returns: { success: true, data: {...product}, metadata: {...} }
  }
}

Multiple Interceptors

Typescript
@Tool({ name: 'get_user' })
@UseInterceptors(
  ResponseWrapperInterceptor,
  DataMaskingInterceptor,
  CacheInterceptor
)
async getUser(input: { userId: string }, ctx: ExecutionContext) {
  return this.userService.findById(input.userId);
}

Common Patterns

Response Transformation

Typescript
@Interceptor()
export class TransformInterceptor implements InterceptorInterface {
  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const result = await next();
    return this.transformKeys(result);
  }

  private transformKeys(data: unknown): unknown {
    if (data === null || data === undefined) return data;

    if (Array.isArray(data)) {
      return data.map(item => this.transformKeys(item));
    }

    if (typeof data === 'object' && !(data instanceof Date)) {
      const transformed: Record<string, unknown> = {};
      for (const [key, value] of Object.entries(data)) {
        // Convert snake_case to camelCase
        const camelKey = key.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase());
        transformed[camelKey] = this.transformKeys(value);
      }
      return transformed;
    }

    return data;
  }
}

Sensitive Data Masking

Typescript
@Interceptor()
export class DataMaskingInterceptor implements InterceptorInterface {
  private static readonly SENSITIVE_FIELDS = [
    'password',
    'ssn',
    'socialSecurityNumber',
    'creditCard',
    'cardNumber',
    'cvv',
    'apiKey',
    'secretKey',
    'accessToken'
  ];

  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const result = await next();
    return this.maskSensitiveData(result);
  }

  private maskSensitiveData(data: unknown): unknown {
    if (data === null || data === undefined) return data;

    if (Array.isArray(data)) {
      return data.map(item => this.maskSensitiveData(item));
    }

    if (typeof data === 'object' && !(data instanceof Date)) {
      const masked: Record<string, unknown> = {};
      for (const [key, value] of Object.entries(data)) {
        if (this.isSensitiveField(key)) {
          masked[key] = this.maskValue(key, value);
        } else {
          masked[key] = this.maskSensitiveData(value);
        }
      }
      return masked;
    }

    return data;
  }

  private isSensitiveField(field: string): boolean {
    const lowerField = field.toLowerCase();
    return DataMaskingInterceptor.SENSITIVE_FIELDS.some(
      sensitive => lowerField.includes(sensitive.toLowerCase())
    );
  }

  private maskValue(field: string, value: unknown): string {
    if (!value) return '***';
    const str = String(value);

    if (field.toLowerCase().includes('ssn')) {
      return `***-**-${str.slice(-4)}`;
    }

    if (field.toLowerCase().includes('card') || field.toLowerCase().includes('credit')) {
      return `****-****-****-${str.slice(-4)}`;
    }

    if (field.toLowerCase().includes('key') || field.toLowerCase().includes('token')) {
      if (str.length > 8) {
        return `${str.slice(0, 4)}...${str.slice(-4)}`;
      }
    }

    return '********';
  }
}

Response Caching

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

@Injectable()
@Interceptor()
export class CacheInterceptor implements InterceptorInterface {
  private cache = new Map<string, { data: unknown; expiresAt: number }>();

  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const cacheKey = this.generateCacheKey(context);

    // Check cache
    const cached = this.cache.get(cacheKey);
    if (cached && cached.expiresAt > Date.now()) {
      context.logger.info('Cache hit', { key: cacheKey });
      return cached.data;
    }

    // Execute handler
    const result = await next();

    // Store in cache (5 minute TTL)
    this.cache.set(cacheKey, {
      data: result,
      expiresAt: Date.now() + 5 * 60 * 1000
    });

    return result;
  }

  private generateCacheKey(context: ExecutionContext): string {
    const toolName = context.toolName || 'unknown';
    const input = JSON.stringify(context.metadata?.input || {});
    return `${toolName}:${input}`;
  }
}

Error Response Formatting

Typescript
@Interceptor()
export class ErrorFormatInterceptor implements InterceptorInterface {
  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    try {
      return await next();
    } catch (error) {
      return {
        success: false,
        error: {
          code: this.getErrorCode(error),
          message: error instanceof Error ? error.message : 'An error occurred',
          timestamp: new Date().toISOString(),
          requestId: context.metadata?.requestId
        }
      };
    }
  }

  private getErrorCode(error: unknown): string {
    if (error instanceof ValidationError) return 'VALIDATION_ERROR';
    if (error instanceof NotFoundError) return 'NOT_FOUND';
    if (error instanceof UnauthorizedError) return 'UNAUTHORIZED';
    return 'INTERNAL_ERROR';
  }
}

Pagination Wrapper

Typescript
@Interceptor()
export class PaginationInterceptor implements InterceptorInterface {
  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const result = await next();

    // Check if result is paginated data
    if (Array.isArray(result) && context.metadata?.input) {
      const input = context.metadata.input as {
        page?: number;
        limit?: number;
        total?: number;
      };

      const page = input.page || 1;
      const limit = input.limit || 20;

      return {
        data: result,
        pagination: {
          page,
          limit,
          total: input.total || result.length,
          hasMore: result.length === limit
        }
      };
    }

    return result;
  }
}

Enterprise Data Spillover Interceptor

In production environments, tools executing database queries, CSV exports, or log aggregations may produce massive responses (e.g., 200KB to 1MB) that exhaust model context windows and cause client timeout errors.

NitroStack provides the DataSpilloverInterceptor to intercept oversized tool payloads. If the serialized response exceeds a configured byte threshold (default: 10KB), the interceptor automatically persists the full payload to the server's spillover storage, generates a preview summary, and returns an MCP resource URI pointer.

Typescript
import {
  Controller,
  ToolDecorator as Tool,
  UseInterceptors,
  ExecutionContext,
  DataSpilloverInterceptor,
  z,
} from '@nitrostack/core';
import { AnalyticsService } from './analytics.service.js';

@Controller('analytics')
export class AnalyticsController {
  constructor(private readonly analytics: AnalyticsService) {}

  @Tool({
    name: 'export_metrics',
    description: 'Export system performance metrics with automatic payload protection',
    inputSchema: z.object({
      timeRange: z.enum(['1h', '24h', '7d']),
    }),
  })
  @UseInterceptors(
    new DataSpilloverInterceptor({
      maxPayloadBytes: 15 * 1024,      // Offload to storage if payload > 15KB
      previewItems: 5,                 // Include first 5 records in preview
      previewStringChars: 300,         // Truncate long strings at 300 chars
      spilloverTtlSeconds: 3600,       // Retain cached resource for 1 hour
    })
  )
  async exportMetrics(input: { timeRange: string }, ctx: ExecutionContext) {
    return await this.analytics.getMetrics(input.timeRange);
  }
}

When triggered, the AI model receives a structured envelope:

JSON
{
  "_spillover": true,
  "resourceUri": "resource://data-spillover/a481d86e-b3f4-411a-8219-...",
  "sizeBytes": 184500,
  "totalItems": 950,
  "summary": "Query returned 950 items (180.2 KB)",
  "preview": [ ... sample items ... ],
  "hint": "The response exceeded the size limit and was saved as an MCP resource. Use resources/read with the resourceUri above to fetch the full data, or inspect the preview above."
}

For complete details on filesystem drivers, memory drivers, and client retrieval, see the dedicated Data Spillover Guide.

Dependency Injection

Interceptors support dependency injection:

Typescript
import { Injectable, Interceptor, InterceptorInterface } from '@nitrostack/core';

@Injectable()
@Interceptor()
export class AuditInterceptor implements InterceptorInterface {
  constructor(
    private auditService: AuditService,
    private configService: ConfigService
  ) {}

  async intercept(context: ExecutionContext, next: () => Promise<any>): Promise<any> {
    const startTime = Date.now();
    const shouldAudit = this.configService.get('ENABLE_AUDIT', true);

    const result = await next();

    if (shouldAudit) {
      await this.auditService.record({
        action: context.toolName,
        userId: context.auth?.subject,
        duration: Date.now() - startTime,
        timestamp: new Date()
      });
    }

    return result;
  }
}

Best Practices

1. Do Not Mutate Original Data

Return new objects instead of modifying the original:

Typescript
// Correct: Return new object
async intercept(context, next) {
  const result = await next();
  return {
    ...result,
    transformed: true
  };
}

// Incorrect: Mutating original
async intercept(context, next) {
  const result = await next();
  result.transformed = true;  // Mutation
  return result;
}

2. Handle Errors Gracefully

Decide whether to catch errors or let them propagate:

Typescript
// Transform errors
async intercept(context, next) {
  try {
    return await next();
  } catch (error) {
    return { error: true, message: error.message };
  }
}

// Or propagate with logging
async intercept(context, next) {
  try {
    return await next();
  } catch (error) {
    context.logger.error('Interceptor caught error', { error });
    throw error;  // Re-throw for upstream handling
  }
}

3. Keep Interceptors Focused

Each interceptor should have a single transformation purpose:

Typescript
// Correct: Focused interceptors
@Interceptor()
export class ResponseWrapperInterceptor { /* Wraps responses */ }

@Interceptor()
export class DataMaskingInterceptor { /* Masks sensitive data */ }

// Incorrect: Combined responsibilities
@Interceptor()
export class DoEverythingInterceptor {
  async intercept(context, next) {
    // Wrapping + masking + caching + logging
  }
}

4. Document Transformations

Clearly document what the interceptor modifies:

Typescript
/**
 * Response Wrapper Interceptor
 *
 * Transforms handler output into standardized response format:
 * {
 *   success: boolean,
 *   data: T,
 *   metadata: { tool, requestId, timestamp, duration }
 * }
 */
@Interceptor()
export class ResponseWrapperInterceptor implements InterceptorInterface {
  // Implementation
}