Express Function Architecture - Complete Implementation

December 5, 2025 · View on GitHub

Complete example of Express-based Cloud Functions with routing, middleware, and tool organization.

Overview

This pattern uses Express.js to build API-like Cloud Functions with:

  • Middleware for authentication, logging, CORS
  • Tool-based routing (MCP pattern)
  • RESTful endpoint support
  • Service layer for business logic
  • Shared types and utilities

Best for: API projects, MCP servers, projects with many related endpoints

Example: oneonone project MCP endpoint

Complete Implementation

Scenario

You're building an MCP server for a 1:1 meeting application. It needs:

  • API key authentication
  • Multiple tools (request_session, send_message, end_session)
  • Session management logic
  • Type safety with TypeScript
  • Health check endpoint
  • Request logging

Step 1: Project Structure

functions/
├── src/
│   ├── index.ts                    # Main entry point with Express app
│   ├── middleware/
│   │   ├── apiKeyGuard.ts          # Authentication middleware
│   │   └── loggingMiddleware.ts    # Request logging
│   ├── tools/
│   │   ├── requestSession.ts       # Tool: request_session
│   │   ├── sendMessage.ts          # Tool: send_message
│   │   └── endSession.ts           # Tool: end_session
│   ├── services/
│   │   └── sessionManager.ts       # Business logic
│   ├── shared/
│   │   ├── types.ts                # Shared TypeScript types
│   │   └── config.ts               # Configuration
│   └── __tests__/
│       ├── middleware/
│       ├── tools/
│       └── integration/
├── package.json
├── tsconfig.json
└── vitest.config.ts

Step 2: Shared Types

functions/src/shared/types.ts:

// ABOUTME: Shared TypeScript types and interfaces for MCP tools and sessions
// ABOUTME: Central source of truth for data structures used across functions

// MCP request/response types
export interface McpRequest {
  tool: string;
  params: Record<string, any>;
}

export interface McpResponse {
  success: boolean;
  data?: any;
  error?: string;
}

// Session types
export interface Session {
  sessionId: string;
  userId: string;
  partnerId: string;
  status: 'pending' | 'active' | 'completed';
  createdAt: Date;
  completedAt?: Date;
}

// Message types
export interface Message {
  messageId: string;
  sessionId: string;
  senderId: string;
  content: string;
  timestamp: Date;
}

// Tool-specific params
export interface RequestSessionParams {
  partnerId: string;
}

export interface SendMessageParams {
  sessionId: string;
  content: string;
}

export interface EndSessionParams {
  sessionId: string;
}

functions/src/shared/config.ts:

// ABOUTME: Configuration constants and environment variable management
// ABOUTME: Centralized config to avoid magic strings and enable easy updates

export const CONFIG = {
  API_KEY_PREFIX: 'ooo_',
  REGION: 'us-central1',
  CORS_ORIGINS: ['http://localhost:3000', 'https://oneonone-main.web.app'],
  MAX_MESSAGE_LENGTH: 1000,
  SESSION_TIMEOUT_MINUTES: 30,
} as const;

Step 3: Authentication Middleware

functions/src/middleware/apiKeyGuard.ts:

// ABOUTME: Express middleware for API key authentication using Firestore collection group
// ABOUTME: Validates x-api-key header, checks active status, attaches userId to request

import * as admin from 'firebase-admin';
import { Request, Response, NextFunction } from 'express';
import { CONFIG } from '../shared/config';

// Extend Express Request to include userId
declare global {
  namespace Express {
    interface Request {
      userId?: string;
    }
  }
}

export async function apiKeyGuard(
  req: Request,
  res: Response,
  next: NextFunction
): Promise<void> {
  const apiKey = req.headers['x-api-key'] as string;

  // Validate header format
  if (!apiKey || !apiKey.startsWith(CONFIG.API_KEY_PREFIX)) {
    res.status(401).json({
      success: false,
      error: 'Invalid or missing API key',
      hint: `Include x-api-key header with format: ${CONFIG.API_KEY_PREFIX}xxxxx`
    });
    return;
  }

  try {
    // Query collectionGroup for API key
    const db = admin.firestore();
    const apiKeysQuery = await db
      .collectionGroup('apiKeys')
      .where('keyId', '==', apiKey)
      .where('active', '==', true)
      .limit(1)
      .get();

    if (apiKeysQuery.empty) {
      res.status(401).json({
        success: false,
        error: 'Invalid API key',
        hint: 'Key may be inactive or deleted'
      });
      return;
    }

    // Attach userId to request
    req.userId = apiKeysQuery.docs[0].data().userId;

    // Update lastUsed timestamp (async, don't block)
    apiKeysQuery.docs[0].ref.update({
      lastUsed: admin.firestore.FieldValue.serverTimestamp()
    }).catch(err => console.error('Failed to update lastUsed:', err));

    next();
  } catch (error) {
    console.error('API key validation error:', error);
    res.status(500).json({
      success: false,
      error: 'Authentication error'
    });
  }
}

Step 4: Logging Middleware

functions/src/middleware/loggingMiddleware.ts:

// ABOUTME: Express middleware for request/response logging
// ABOUTME: Logs method, path, userId, duration, and status for debugging

import { Request, Response, NextFunction } from 'express';

export function loggingMiddleware(
  req: Request,
  res: Response,
  next: NextFunction
): void {
  const startTime = Date.now();

  // Log request
  console.log(`[${new Date().toISOString()}] ${req.method} ${req.path}`, {
    userId: req.userId || 'unauthenticated',
    body: req.body,
  });

  // Capture original send
  const originalSend = res.send;

  // Override send to log response
  res.send = function (body: any): Response {
    const duration = Date.now() - startTime;
    console.log(`[${new Date().toISOString()}] ${req.method} ${req.path} ${res.statusCode}`, {
      userId: req.userId || 'unauthenticated',
      duration: `${duration}ms`,
      status: res.statusCode,
    });
    return originalSend.call(this, body);
  };

  next();
}

Step 5: Service Layer

functions/src/services/sessionManager.ts:

// ABOUTME: Business logic for session management (create, end, validate)
// ABOUTME: Encapsulates Firestore operations and session state transitions

import * as admin from 'firebase-admin';
import { Session } from '../shared/types';

export class SessionManager {
  private db: admin.firestore.Firestore;

  constructor() {
    this.db = admin.firestore();
  }

  async createSession(userId: string, partnerId: string): Promise<Session> {
    // Validate users exist
    const userDoc = await this.db.collection('users').doc(userId).get();
    const partnerDoc = await this.db.collection('users').doc(partnerId).get();

    if (!userDoc.exists || !partnerDoc.exists) {
      throw new Error('User or partner not found');
    }

    // Check for existing active session
    const existingSession = await this.db
      .collection('sessions')
      .where('userId', '==', userId)
      .where('partnerId', '==', partnerId)
      .where('status', '==', 'active')
      .limit(1)
      .get();

    if (!existingSession.empty) {
      throw new Error('Active session already exists');
    }

    // Create new session
    const sessionRef = this.db.collection('sessions').doc();
    const session: Session = {
      sessionId: sessionRef.id,
      userId,
      partnerId,
      status: 'pending',
      createdAt: new Date(),
    };

    await sessionRef.set(session);
    return session;
  }

  async getSession(sessionId: string): Promise<Session | null> {
    const sessionDoc = await this.db.collection('sessions').doc(sessionId).get();

    if (!sessionDoc.exists) {
      return null;
    }

    return sessionDoc.data() as Session;
  }

  async endSession(sessionId: string, userId: string): Promise<void> {
    const sessionRef = this.db.collection('sessions').doc(sessionId);
    const sessionDoc = await sessionRef.get();

    if (!sessionDoc.exists) {
      throw new Error('Session not found');
    }

    const session = sessionDoc.data() as Session;

    // Verify user is participant
    if (session.userId !== userId && session.partnerId !== userId) {
      throw new Error('Not authorized to end this session');
    }

    // Update session status
    await sessionRef.update({
      status: 'completed',
      completedAt: admin.firestore.FieldValue.serverTimestamp(),
    });
  }

  async addMessage(sessionId: string, senderId: string, content: string): Promise<void> {
    // Verify session exists and is active
    const session = await this.getSession(sessionId);
    if (!session) {
      throw new Error('Session not found');
    }
    if (session.status !== 'active') {
      throw new Error('Session is not active');
    }

    // Verify sender is participant
    if (session.userId !== senderId && session.partnerId !== senderId) {
      throw new Error('Not authorized to send messages in this session');
    }

    // Add message
    const messageRef = this.db
      .collection('sessions')
      .doc(sessionId)
      .collection('messages')
      .doc();

    await messageRef.set({
      messageId: messageRef.id,
      sessionId,
      senderId,
      content,
      timestamp: admin.firestore.FieldValue.serverTimestamp(),
    });
  }
}

Step 6: Tool Handlers

functions/src/tools/requestSession.ts:

// ABOUTME: Handler for request_session tool - creates new 1:1 session
// ABOUTME: Validates partnerId and returns session details

import { McpResponse, RequestSessionParams } from '../shared/types';
import { SessionManager } from '../services/sessionManager';

export async function handleRequestSession(
  userId: string,
  params: RequestSessionParams
): Promise<McpResponse> {
  try {
    const sessionManager = new SessionManager();
    const session = await sessionManager.createSession(userId, params.partnerId);

    return {
      success: true,
      data: {
        sessionId: session.sessionId,
        status: session.status,
        partnerId: session.partnerId,
      },
    };
  } catch (error: any) {
    console.error('Failed to request session:', error);
    return {
      success: false,
      error: error.message || 'Failed to create session',
    };
  }
}

functions/src/tools/sendMessage.ts:

// ABOUTME: Handler for send_message tool - adds message to active session
// ABOUTME: Validates session status and sender authorization

import { McpResponse, SendMessageParams } from '../shared/types';
import { SessionManager } from '../services/sessionManager';
import { CONFIG } from '../shared/config';

export async function handleSendMessage(
  userId: string,
  params: SendMessageParams
): Promise<McpResponse> {
  try {
    // Validate message content
    if (!params.content || params.content.trim().length === 0) {
      return {
        success: false,
        error: 'Message content cannot be empty',
      };
    }

    if (params.content.length > CONFIG.MAX_MESSAGE_LENGTH) {
      return {
        success: false,
        error: `Message exceeds maximum length of ${CONFIG.MAX_MESSAGE_LENGTH} characters`,
      };
    }

    const sessionManager = new SessionManager();
    await sessionManager.addMessage(params.sessionId, userId, params.content);

    return {
      success: true,
      data: {
        messageId: 'generated-by-firestore', // Would come from sessionManager
        timestamp: new Date().toISOString(),
      },
    };
  } catch (error: any) {
    console.error('Failed to send message:', error);
    return {
      success: false,
      error: error.message || 'Failed to send message',
    };
  }
}

functions/src/tools/endSession.ts:

// ABOUTME: Handler for end_session tool - marks session as completed
// ABOUTME: Validates user is participant before allowing completion

import { McpResponse, EndSessionParams } from '../shared/types';
import { SessionManager } from '../services/sessionManager';

export async function handleEndSession(
  userId: string,
  params: EndSessionParams
): Promise<McpResponse> {
  try {
    const sessionManager = new SessionManager();
    await sessionManager.endSession(params.sessionId, userId);

    return {
      success: true,
      data: {
        sessionId: params.sessionId,
        status: 'completed',
      },
    };
  } catch (error: any) {
    console.error('Failed to end session:', error);
    return {
      success: false,
      error: error.message || 'Failed to end session',
    };
  }
}

Step 7: Main Express App

functions/src/index.ts:

// ABOUTME: Main entry point for Firebase Functions - exports MCP endpoint with tool routing
// ABOUTME: Configures Express app with authentication, CORS, logging, and health check

import * as admin from 'firebase-admin';
import { onRequest } from 'firebase-functions/v2/https';
import express, { Request, Response } from 'express';
import cors from 'cors';
import { apiKeyGuard } from './middleware/apiKeyGuard';
import { loggingMiddleware } from './middleware/loggingMiddleware';
import { handleRequestSession } from './tools/requestSession';
import { handleSendMessage } from './tools/sendMessage';
import { handleEndSession } from './tools/endSession';
import { CONFIG } from './shared/config';
import type { McpRequest, McpResponse } from './shared/types';

// Initialize Firebase Admin
admin.initializeApp();

// Create Express app
const app = express();

// Middleware
app.use(cors({ origin: CONFIG.CORS_ORIGINS }));
app.use(express.json());
app.use(loggingMiddleware);

// Health check endpoint (no auth required)
app.get('/health', (_req: Request, res: Response) => {
  res.status(200).json({
    status: 'ok',
    timestamp: new Date().toISOString(),
    version: '1.0.0',
  });
});

// MCP endpoint (auth required)
app.post('/mcp', apiKeyGuard, async (req: Request, res: Response) => {
  const { tool, params } = req.body as McpRequest;
  const userId = req.userId!; // Guaranteed by apiKeyGuard

  // Validate request format
  if (!tool || typeof tool !== 'string') {
    res.status(400).json({
      success: false,
      error: 'Missing or invalid tool parameter',
    });
    return;
  }

  // Route to appropriate tool handler
  let result: McpResponse;
  switch (tool) {
    case 'request_session':
      result = await handleRequestSession(userId, params);
      break;

    case 'send_message':
      result = await handleSendMessage(userId, params);
      break;

    case 'end_session':
      result = await handleEndSession(userId, params);
      break;

    default:
      result = {
        success: false,
        error: `Unknown tool: ${tool}`,
        hint: 'Available tools: request_session, send_message, end_session',
      };
  }

  // Return result
  res.status(result.success ? 200 : 400).json(result);
});

// 404 handler
app.use((_req: Request, res: Response) => {
  res.status(404).json({
    success: false,
    error: 'Not found',
    hint: 'Available endpoints: GET /health, POST /mcp',
  });
});

// Export as Cloud Function
export const mcpEndpoint = onRequest(
  {
    invoker: 'public',
    cors: true,
    region: CONFIG.REGION,
  },
  app
);

Step 8: Configuration Files

functions/package.json:

{
  "name": "functions",
  "private": true,
  "engines": {
    "node": "20"
  },
  "scripts": {
    "build": "tsc",
    "dev": "tsc --watch",
    "serve": "npm run build && firebase emulators:start --only functions",
    "shell": "npm run build && firebase functions:shell",
    "deploy": "firebase deploy --only functions",
    "lint": "biome lint ./src",
    "lint:fix": "biome lint --apply ./src",
    "test": "vitest run",
    "test:watch": "vitest",
    "test:emulator": "vitest run --config vitest.emulator.config.ts"
  },
  "dependencies": {
    "cors": "^2.8.5",
    "express": "^4.18.2",
    "firebase-admin": "^12.0.0",
    "firebase-functions": "^5.0.0"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.5.0",
    "@types/cors": "^2.8.17",
    "@types/express": "^4.17.21",
    "@types/node": "^20.11.0",
    "typescript": "^5.3.3",
    "vitest": "^1.2.0"
  }
}

functions/tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2019",
    "module": "commonjs",
    "lib": ["ES2019"],
    "outDir": "lib",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "moduleResolution": "node"
  },
  "include": ["src/**/*"],
  "exclude": ["node_modules", "lib", "**/*.test.ts"]
}

Step 9: Testing

functions/src/tests/middleware/apiKeyGuard.test.ts:

// ABOUTME: Unit tests for API key authentication middleware
// ABOUTME: Tests validation, error cases, and userId attachment

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Request, Response, NextFunction } from 'express';
import { apiKeyGuard } from '../../middleware/apiKeyGuard';

describe('apiKeyGuard', () => {
  let req: Partial<Request>;
  let res: Partial<Response>;
  let next: NextFunction;

  beforeEach(() => {
    req = {
      headers: {},
    };
    res = {
      status: vi.fn().mockReturnThis(),
      json: vi.fn(),
    };
    next = vi.fn();
  });

  it('should reject request with no API key', async () => {
    await apiKeyGuard(req as Request, res as Response, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(res.json).toHaveBeenCalledWith(
      expect.objectContaining({
        success: false,
        error: 'Invalid or missing API key',
      })
    );
    expect(next).not.toHaveBeenCalled();
  });

  it('should reject API key with wrong prefix', async () => {
    req.headers = { 'x-api-key': 'wrong_prefix_key' };

    await apiKeyGuard(req as Request, res as Response, next);

    expect(res.status).toHaveBeenCalledWith(401);
    expect(next).not.toHaveBeenCalled();
  });

  // Integration tests with emulators would go in vitest.emulator.config.ts
});

functions/src/tests/tools/requestSession.test.ts:

// ABOUTME: Unit tests for request_session tool handler
// ABOUTME: Tests session creation, validation, and error handling

import { describe, it, expect, vi } from 'vitest';
import { handleRequestSession } from '../../tools/requestSession';
import { SessionManager } from '../../services/sessionManager';

vi.mock('../../services/sessionManager');

describe('handleRequestSession', () => {
  it('should create session successfully', async () => {
    const mockSession = {
      sessionId: 'session-123',
      userId: 'user-1',
      partnerId: 'user-2',
      status: 'pending',
      createdAt: new Date(),
    };

    vi.mocked(SessionManager.prototype.createSession).mockResolvedValue(mockSession);

    const result = await handleRequestSession('user-1', { partnerId: 'user-2' });

    expect(result.success).toBe(true);
    expect(result.data?.sessionId).toBe('session-123');
  });

  it('should handle errors gracefully', async () => {
    vi.mocked(SessionManager.prototype.createSession).mockRejectedValue(
      new Error('User not found')
    );

    const result = await handleRequestSession('user-1', { partnerId: 'invalid' });

    expect(result.success).toBe(false);
    expect(result.error).toContain('User not found');
  });
});

Step 10: Local Development

Start emulators:

# Terminal 1: Start emulators
firebase emulators:start

# Terminal 2: Watch TypeScript compilation
cd functions && npm run dev

Test endpoints:

# Health check (no auth)
curl http://127.0.0.1:5001/your-project/us-central1/mcpEndpoint/health

# Request session (with auth)
curl -X POST http://127.0.0.1:5001/your-project/us-central1/mcpEndpoint/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: ooo_test123" \
  -d '{"tool": "request_session", "params": {"partnerId": "user-456"}}'

# Send message
curl -X POST http://127.0.0.1:5001/your-project/us-central1/mcpEndpoint/mcp \
  -H "Content-Type: application/json" \
  -H "x-api-key: ooo_test123" \
  -d '{"tool": "send_message", "params": {"sessionId": "session-123", "content": "Hello!"}}'

Key Design Patterns

1. Middleware Chain

  • CORS → JSON parser → Logging → Auth → Route handler
  • Each middleware has single responsibility
  • Middleware can short-circuit with errors

2. Service Layer Separation

  • Business logic in services (SessionManager)
  • Tool handlers are thin wrappers
  • Easy to test services independently
  • Can reuse services across multiple endpoints

3. Type Safety

  • Shared types in shared/types.ts
  • Express Request extended with userId
  • All handlers return typed McpResponse
  • TypeScript catches errors at compile time

4. Error Handling

  • Try/catch in all async handlers
  • Helpful error messages with hints
  • Consistent error response format
  • Never expose internal errors to clients

5. Configuration Management

  • Centralized in shared/config.ts
  • No magic strings in code
  • Easy to update across project
  • Type-safe with as const

Advantages

✅ Familiar Express patterns ✅ Middleware for cross-cutting concerns ✅ Easy to add new endpoints ✅ Good separation of concerns ✅ Testable in isolation ✅ Type-safe with TypeScript ✅ Scales well for API projects

When to Use

Use Express pattern when:

  • Building API with multiple related endpoints
  • Need middleware (auth, logging, CORS)
  • Familiar with Express ecosystem
  • Want RESTful routing patterns
  • Project has 5+ endpoints

Don't use when:

  • Project has 1-2 simple functions
  • No shared middleware needed
  • Prefer individual function files
  • Maximum modularity required

References

  • Production example: /Users/dylanr/work/2389/oneonone/functions/src/index.ts
  • Main skill: firebase-development/SKILL.md → Cloud Functions Architecture section