|

Understanding MCP Server Architecture

So you’ve built type-safe APIs with Zod. Cool. But what if you need a server that’s more than just REST endpoints? Something that can maintain context, handle complex workflows, and actually grow with your app? That’s where MCP comes in.

What’s MCP anyway?

Model Context Protocol (MCP) is basically a pattern for building servers that remember stuff between requests. Unlike your typical stateless REST API that forgets everything after each request, MCP servers keep track of context and state.

Think of it like this: REST is a goldfish (forgets everything), MCP is more like… a smart assistant that remembers your conversation.

MCP handles:

  • Keeping context alive across requests
  • Managing state transitions
  • Protocol stuff like auth
  • Making your server extensible with plugins
interface MCPServer {
  initialize(): Promise<void>
  handleRequest(request: MCPRequest): Promise<MCPResponse>
  shutdown(): Promise<void>
}

interface MCPRequest {
  id: string
  method: string
  params: unknown
  context?: Record<string, unknown>
}

interface MCPResponse {
  id: string
  result?: unknown
  error?: MCPError
}

The pieces that make it work

An MCP server has a few key parts:

Request Router: Figures out which handler should deal with each request.

Context Manager: Keeps track of state between requests. This is the magic part.

Handler Registry: Maps methods to the code that handles them.

Protocol Layer: Deals with serialization and protocol stuff.

Lifecycle Manager: Handles startup, shutdown, and cleanup.

class MCPServerCore {
  private router: RequestRouter
  private contextManager: ContextManager
  private handlerRegistry: HandlerRegistry
  private protocol: ProtocolLayer
  private lifecycle: LifecycleManager

  constructor(config: MCPServerConfig) {
    this.router = new RequestRouter()
    this.contextManager = new ContextManager(config.contextOptions)
    this.handlerRegistry = new HandlerRegistry()
    this.protocol = new ProtocolLayer(config.protocolOptions)
    this.lifecycle = new LifecycleManager()
  }

  async initialize(): Promise<void> {
    await this.lifecycle.runPhase('initialize', async () => {
      await this.contextManager.initialize()
      await this.protocol.initialize()
      this.registerCoreHandlers()
    })
  }

  async handleRequest(request: MCPRequest): Promise<MCPResponse> {
    const context = await this.contextManager.getContext(request)
    const handler = this.handlerRegistry.getHandler(request.method)
    
    if (!handler) {
      return {
        id: request.id,
        error: {
          code: -32601,
          message: `Method not found: ${request.method}`
        }
      }
    }

    try {
      const result = await handler(request.params, context)
      await this.contextManager.updateContext(request, result)
      
      return {
        id: request.id,
        result
      }
    } catch (error) {
      return {
        id: request.id,
        error: this.formatError(error)
      }
    }
  }

  registerHandler(method: string, handler: RequestHandler): void {
    this.handlerRegistry.register(method, handler)
  }

  private registerCoreHandlers(): void {
    this.registerHandler('server.info', this.handleServerInfo.bind(this))
    this.registerHandler('server.ping', this.handlePing.bind(this))
  }

  private async handleServerInfo(): Promise<ServerInfo> {
    return {
      name: 'MCP Server',
      version: '1.0.0',
      capabilities: ['context', 'plugins', 'streaming']
    }
  }

  private async handlePing(): Promise<{ pong: boolean }> {
    return { pong: true }
  }

  private formatError(error: unknown): MCPError {
    if (error instanceof Error) {
      return {
        code: -32000,
        message: error.message,
        data: { stack: error.stack }
      }
    }
    return {
      code: -32000,
      message: 'Unknown error occurred'
    }
  }
}

How requests flow through the system

Here’s what happens when a request comes in:

  1. Server gets the raw request
  2. Protocol layer turns it into an MCPRequest object
  3. Context manager loads (or creates) context
  4. Router finds the right handler
  5. Request gets validated
  6. Handler does its thing
  7. Context gets updated
  8. Response goes back to client

Simple, right? The key is that context sticks around between requests.

class RequestLifecycle {
  constructor(
    private protocol: ProtocolLayer,
    private contextManager: ContextManager,
    private router: RequestRouter,
    private validator: RequestValidator
  ) {}

  async process(rawRequest: Buffer): Promise<Buffer> {
    let request: MCPRequest | null = null

    try {
      request = await this.protocol.deserialize(rawRequest)
      
      const context = await this.contextManager.loadContext(request)
      
      const handler = this.router.route(request.method)
      if (!handler) {
        throw new Error(`No handler for method: ${request.method}`)
      }
      
      await this.validator.validate(request, handler.schema)
      
      const result = await handler.execute(request.params, context)
      
      await this.contextManager.saveContext(request, context)
      
      const response: MCPResponse = {
        id: request.id,
        result
      }
      
      return await this.protocol.serialize(response)
    } catch (error) {
      const errorResponse: MCPResponse = {
        id: request?.id || 'unknown',
        error: this.formatError(error)
      }
      
      return await this.protocol.serialize(errorResponse)
    }
  }

  private formatError(error: unknown): MCPError {
    if (error instanceof ValidationError) {
      return {
        code: -32602,
        message: 'Invalid params',
        data: error.details
      }
    }
    
    if (error instanceof Error) {
      return {
        code: -32000,
        message: error.message
      }
    }
    
    return {
      code: -32000,
      message: 'Unknown error'
    }
  }
}

Managing connections

MCP servers often keep connections open. You need to handle that properly:

class ConnectionManager {
  private connections: Map<string, Connection> = new Map()
  private heartbeatInterval: NodeJS.Timeout | null = null

  async addConnection(connectionId: string, socket: WebSocket): Promise<void> {
    const connection: Connection = {
      id: connectionId,
      socket,
      createdAt: Date.now(),
      lastActivity: Date.now(),
      context: {}
    }

    this.connections.set(connectionId, connection)
    
    socket.on('message', (data) => this.handleMessage(connectionId, data))
    socket.on('close', () => this.removeConnection(connectionId))
    socket.on('error', (error) => this.handleError(connectionId, error))
    
    await this.sendWelcome(connection)
  }

  async removeConnection(connectionId: string): Promise<void> {
    const connection = this.connections.get(connectionId)
    if (!connection) return

    try {
      await this.cleanupContext(connection)
      connection.socket.close()
    } finally {
      this.connections.delete(connectionId)
    }
  }

  startHeartbeat(intervalMs: number = 30000): void {
    this.heartbeatInterval = setInterval(() => {
      this.checkConnections()
    }, intervalMs)
  }

  stopHeartbeat(): void {
    if (this.heartbeatInterval) {
      clearInterval(this.heartbeatInterval)
      this.heartbeatInterval = null
    }
  }

  private checkConnections(): void {
    const now = Date.now()
    const timeout = 60000 // 60 seconds

    for (const [id, connection] of this.connections) {
      if (now - connection.lastActivity > timeout) {
        this.removeConnection(id)
      }
    }
  }

  private async handleMessage(
    connectionId: string,
    data: WebSocket.Data
  ): Promise<void> {
    const connection = this.connections.get(connectionId)
    if (!connection) return

    connection.lastActivity = Date.now()
    
    // Process message through MCP server
  }

  private async sendWelcome(connection: Connection): Promise<void> {
    const welcome = {
      type: 'welcome',
      connectionId: connection.id,
      serverInfo: {
        name: 'MCP Server',
        version: '1.0.0'
      }
    }
    
    connection.socket.send(JSON.stringify(welcome))
  }

  private async cleanupContext(connection: Connection): Promise<void> {
    // Clean up any resources associated with this connection
  }

  private handleError(connectionId: string, error: Error): void {
    console.error(`Connection ${connectionId} error:`, error)
    this.removeConnection(connectionId)
  }
}

State management strategies

You’ve got options for storing state:

In-Memory: Fast but gone when you restart. Good for session data.

class InMemoryStateStore {
  private state: Map<string, ContextState> = new Map()

  async get(contextId: string): Promise<ContextState | null> {
    return this.state.get(contextId) || null
  }

  async set(contextId: string, state: ContextState): Promise<void> {
    this.state.set(contextId, state)
  }

  async delete(contextId: string): Promise<void> {
    this.state.delete(contextId)
  }

  async clear(): Promise<void> {
    this.state.clear()
  }
}

Persistent: Survives restarts but slower. Use for important stuff.

class PersistentStateStore {
  constructor(private db: Database) {}

  async get(contextId: string): Promise<ContextState | null> {
    const row = await this.db.query(
      'SELECT state FROM context_state WHERE id = ?',
      [contextId]
    )
    
    return row ? JSON.parse(row.state) : null
  }

  async set(contextId: string, state: ContextState): Promise<void> {
    await this.db.query(
      'INSERT OR REPLACE INTO context_state (id, state, updated_at) VALUES (?, ?, ?)',
      [contextId, JSON.stringify(state), Date.now()]
    )
  }

  async delete(contextId: string): Promise<void> {
    await this.db.query('DELETE FROM context_state WHERE id = ?', [contextId])
  }
}

Hybrid: Best of both worlds. Cache in memory, persist to disk.

class HybridStateStore {
  constructor(
    private memory: InMemoryStateStore,
    private persistent: PersistentStateStore
  ) {}

  async get(contextId: string): Promise<ContextState | null> {
    let state = await this.memory.get(contextId)
    
    if (!state) {
      state = await this.persistent.get(contextId)
      if (state) {
        await this.memory.set(contextId, state)
      }
    }
    
    return state
  }

  async set(contextId: string, state: ContextState): Promise<void> {
    await this.memory.set(contextId, state)
    await this.persistent.set(contextId, state)
  }

  async delete(contextId: string): Promise<void> {
    await this.memory.delete(contextId)
    await this.persistent.delete(contextId)
  }
}

Security matters

Don’t skip this part. Layer your security:

class SecurityLayer {
  constructor(
    private authProvider: AuthProvider,
    private rateLimiter: RateLimiter,
    private validator: InputValidator
  ) {}

  async authenticate(request: MCPRequest): Promise<AuthContext> {
    const token = this.extractToken(request)
    
    if (!token) {
      throw new AuthError('Missing authentication token')
    }

    const authContext = await this.authProvider.verify(token)
    
    if (!authContext.isValid) {
      throw new AuthError('Invalid authentication token')
    }

    return authContext
  }

  async authorize(
    authContext: AuthContext,
    method: string
  ): Promise<boolean> {
    const permissions = authContext.permissions
    const requiredPermission = this.getRequiredPermission(method)

    return permissions.includes(requiredPermission)
  }

  async checkRateLimit(clientId: string): Promise<void> {
    const allowed = await this.rateLimiter.checkLimit(clientId)
    
    if (!allowed) {
      throw new RateLimitError('Rate limit exceeded')
    }
  }

  async validateInput(request: MCPRequest): Promise<void> {
    await this.validator.validate(request.params)
  }

  private extractToken(request: MCPRequest): string | null {
    return request.context?.authorization as string || null
  }

  private getRequiredPermission(method: string): string {
    const methodPermissions: Record<string, string> = {
      'user.create': 'user:write',
      'user.read': 'user:read',
      'user.update': 'user:write',
      'user.delete': 'user:delete'
    }

    return methodPermissions[method] || 'default'
  }
}

Making it fast

Some tricks I use:

Batch requests: Process multiple requests together when you can.

Pool connections: Reuse database and service connections.

Cache stuff: Don’t hit the database for the same data over and over.

Go async: Use async/await everywhere. Don’t block.

Wrapping up

MCP architecture gives you a solid base for building servers that actually scale. The key pieces - routing, context management, connections, state, security - they all work together.

These patterns work in production. I’ve used them. They scale from small projects to big ones.

Next post: plugins. We’ll make this server extensible without touching the core code.

Keep pushing forward and savor every step of your coding journey.