back to posts
#01 Part 1 2025-09-25 15 min

From Monolithic to Modular: The 5,847-Line index.ts Nightmare

How we refactored 17 massive single-file MCP servers into organized, maintainable architectures

Part 1 of the Journey: The Chaos Era - The Problem & The Pain Next: The TypeScript Migration Journey

From Monolithic to Modular: The 5,847-Line index.ts Nightmare

How we refactored 17 massive single-file MCP servers into organized, maintainable architectures

Historical Context (November 2025): Built July-August 2025, during the first 60 days of the MCP packaging ecosystem. Manual server development was standard practice—we were discovering architectural patterns while the tooling was being invented. This documents real problems we encountered before best practices existed.

Date: September 25, 2025 Author: Myron Koch & Claude Code Category: Refactoring War Stories

The Horror

Opening ethereum-mcp-server/index.js for the first time:

// Line 1
import { Server } from '@modelcontextprotocol/sdk/server/index.js';

// Line 187
function validateAddress(address) {
  // ... validation logic
}

// Line 423
async function getBalance(args) {
  // ... balance logic
}

// Line 687
const NETWORK_CONFIG = {
  // ... 200 lines of config
}

// Line 1,245
async function deployContract(bytecode, abi) {
  // ... deployment logic
}

// Line 2,890
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  // THE SWITCH STATEMENT FROM HELL
  switch(name) {
    case 'eth_get_balance':
      // ... 50 lines
    case 'eth_send_transaction':
      // ... 120 lines
    case 'eth_deploy_contract':
      // ... 200 lines
    // ... 43 more cases
  }
});

// Line 5,847
startServer();

One file. 5,847 lines. 47 tools. Zero organization.

The Archaeology

First, we had to understand what was even IN these files:

// scripts/analyze-monolith.js
const content = fs.readFileSync('index.js', 'utf8');
const lines = content.split('\n');

let stats = {
  imports: 0,
  functions: 0,
  configs: 0,
  tools: 0,
  helpers: 0,
  unknown: 0
};

// Archaeological dig through the code
lines.forEach((line, index) => {
  if (line.includes('import')) stats.imports++;
  else if (line.includes('async function')) stats.functions++;
  else if (line.includes('case \'')) stats.tools++;
  else if (line.includes('const') && index < 1000) stats.configs++;
  // ... more pattern detection
});

console.log(`
Found in ethereum-mcp-server/index.js:
- ${stats.imports} imports (scattered throughout)
- ${stats.functions} functions (mixed purposes)
- ${stats.tools} tool handlers (in switch statement)
- ${stats.configs} config constants (why?)
- ${stats.unknown} lines of mystery code
`);

The Extraction Surgery

Phase 1: Identify Tool Boundaries

We built a parser to find tool implementations:

// scripts/extract-tools.js
function extractTools(monolithPath) {
  const content = fs.readFileSync(monolithPath, 'utf8');
  const tools = [];

  // Find the giant switch statement
  const switchMatch = content.match(/switch\s*\(name\)\s*{([\s\S]*?)^}/m);
  const switchContent = switchMatch[1];

  // Extract each case
  const caseRegex = /case\s+['"]([^'"']+)['"]:[
\s\S]*?(?=case\s+['"]|default:|$)/g;
  let match;

  while ((match = caseRegex.exec(switchContent)) !== null) {
    const toolName = match[1];
    const toolCode = extractUntilBreak(match[0]);
    tools.push({ name: toolName, code: toolCode });
  }

  return tools;
}

Phase 2: Trace Dependencies

Each tool touched random functions scattered throughout:

function traceDependencies(toolCode, monolithContent) {
  const dependencies = new Set();

  // Find all function calls
  const calls = toolCode.match(/\b(\w+)\s*\(/g) || [];

  calls.forEach(call => {
    const funcName = call.replace('(', '').trim();

    // Find function definition
    const funcRegex = new RegExp(`function\s+${funcName}\s*\([^)]*\)\s*{([^}]+)}`, 'g');
    const funcMatch = funcRegex.exec(monolithContent);

    if (funcMatch) {
      dependencies.add({
        name: funcName,
        code: funcMatch[0],
        line: getLineNumber(monolithContent, funcMatch.index)
      });
    }
  });

  return dependencies;
}

Phase 3: Generate Modular Files

The actual extraction:

function generateModularTool(tool, dependencies, prefix) {
  const fileName = `${prefix}-${camelToKebab(tool.name.replace(prefix + '_', ''))}.ts`;

  const content = `
import { BlockchainClient } from '../../client.js';
${generateImports(dependencies)}

export async function handle${capitalizeFirst(tool.name)}(
  args: any,
  client: BlockchainClient
): Promise<{ content: Array<{ type: string; text: string }> }> {
${cleanupToolCode(tool.code)}
}

${dependencies.map(dep => dep.code).join('\n\n')}
`;

  const outputPath = `src/tools/core/${fileName}`;
  fs.writeFileSync(outputPath, content);
  console.log(`Created: ${outputPath}`);
}

The Untangling

The hardest part? Shared state:

// OLD: Global state in monolith
let web3Instance;
let currentNetwork = 'mainnet';
let gasPrice = null;
const tokenCache = {};

// NEW: Client encapsulation
export class EthereumClient {
  private web3: Web3;
  private network: string;
  private cache: Map<string, any>;

  constructor(network: string = 'mainnet') {
    this.network = network;
    this.web3 = new Web3(NETWORKS[network].rpcUrl);
    this.cache = new Map();
  }
}

The Config Extraction

Configurations were scattered like landmines:

// Found these gems throughout the monolith:

// Line 234
const MAINNET_RPC = 'https://mainnet.infura.io/v3/';

// Line 1,567
const GAS_LIMIT = 21000;

// Line 3,234
const NETWORKS = { /* finally, some organization */ };

// Line 4,890
const DEFAULT_SLIPPAGE = 0.5;

Consolidated into:

// src/config/network.ts
export const NETWORKS = {
  mainnet: {
    rpcUrl: process.env.ETH_MAINNET_RPC || 'https://mainnet.infura.io/v3/',
    chainId: 1,
    explorer: 'https://etherscan.io',
    gasLimit: 21000,
    defaultSlippage: 0.5
  },
  // ... all configs in ONE place
};

The Index.ts Shrinking

The beautiful result:

// NEW src/index.ts - Just 287 lines!
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { EthereumClient } from './client.js';

// Import all tool handlers
import { handleEthGetBalance } from './tools/core/eth-get-balance.js';
import { handleEthGetTransaction } from './tools/core/eth-get-transaction.js';
// ... more imports

// Clean tool registry
const toolHandlers: Record<string, Function> = {
  'eth_get_balance': handleEthGetBalance,
  'eth_get_transaction': handleEthGetTransaction,
  // ... just registration, no implementation
};

// Simple request handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
  const { name, arguments: args } = request.params;

  if (toolHandlers[name]) {
    return await toolHandlers[name](args, client);
  }

  throw new Error(`Unknown tool: ${name}`);
});

The Numbers

Ethereum MCP Server Transformation:

Before:

After:

The Patterns We Found

Monolithic servers all had the same anti-patterns:

1. The Kitchen Sink Import Section

// 50+ imports at the top
import Web3 from 'web3';
import { ethers } from 'ethers';
import axios from 'axios';
import fs from 'fs';
import path from 'path';
import crypto from 'crypto';
// ... why is crypto here? who knows!

2. The Random Helper Function Graveyard

// Scattered throughout like breadcrumbs
function sleep(ms) { /* ... */ }        // Line 234
function retry(fn) { /* ... */ }        // Line 1,890
function sleep2(ms) { /* ... */ }       // Line 3,456 (yes, sleep2!)
function retryWithBackoff(fn) { /* ... */ }  // Line 4,234

3. The Configuration Archaeology Layers

// Different eras of development visible
const RPC_URL = 'old.rpc.url';  // 2023
const RPC_ENDPOINT = 'newer.rpc';  // 2024
const NETWORK_CONFIG = { rpc: 'newest.rpc' };  // 2025
// All three still referenced somewhere!

The Automated Refactoring Script

We built the ultimate refactoring weapon:

#!/usr/bin/env node
// scripts/refactor-monolith.js

async function refactorMonolithicServer(serverPath) {
  console.log(`🔨 Refactoring ${serverPath}`);

  // 1. Backup original
  execSync(`cp ${serverPath}/index.js ${serverPath}/index.js.backup`);

  // 2. Extract tools
  const tools = extractTools(`${serverPath}/index.js`);
  console.log(`📦 Extracted ${tools.length} tools`);

  // 3. Generate modular structure
  createDirectoryStructure(serverPath);

  // 4. Generate tool files
  tools.forEach(tool => {
    generateToolFile(tool, serverPath);
  });

  // 5. Generate new index.ts
  generateCleanIndex(tools, serverPath);

  // 6. Generate client.ts
  generateClientAbstraction(serverPath);

  // 7. Generate tests
  generateTestSuites(tools, serverPath);

  // 8. Update package.json
  updatePackageJson(serverPath);

  console.log(`✅ Refactored successfully!`);
  console.log(`   Before: 1 file, ${getLineCount(`${serverPath}/index.js.backup`)} lines`);
  console.log(`   After: ${countFiles(serverPath)} files, modular structure`);
}

// Run on all servers
const servers = findMonolithicServers();
servers.forEach(server => refactorMonolithicServer(server));

The Lessons Learned

1. Start Modular, Stay Sane

Every new server now starts with the modular structure. No exceptions.

2. One Tool, One File, One Purpose

Never again will we have a tool implementation scattered across 500 lines.

3. The Client Pattern is Sacred

All blockchain interaction through a client class. Always.

4. Tests are Part of Refactoring

No refactor is complete without tests. The tests often reveal hidden bugs.

5. Keep the Old File

We kept .backup files. Saved us multiple times when we needed to check original logic.

The Unexpected Discovery

During refactoring, we found:

The Final Structure

Every server now follows this pattern:

src/
├── index.ts (< 300 lines - just wiring)
├── client.ts (blockchain interaction)
├── config/
│   └── network.ts (all configuration)
├── tools/
│   ├── core/ (25 required tools)
│   ├── wallet/ (5-10 wallet tools)
│   ├── tokens/ (10-15 token tools)
│   ├── nft/ (if supported)
│   ├── defi/ (if applicable)
│   └── [domain]/ (chain-specific)
├── types/
│   └── index.ts (TypeScript definitions)
└── utils/
    ├── logger.ts (never console.log)
    ├── validation.ts (input validation)
    └── format.ts (output formatting)

The Refactoring Checklist

For anyone facing their own monolith:

The Victory

17 monolithic nightmares transformed into organized, maintainable, testable architectures.

Total lines refactored: 67,234 Total files created: 1,247 Sanity preserved: Priceless

References


This is part of our ongoing series documenting architectural patterns and insights from building the Blockchain MCP Server Ecosystem. Sometimes you have to burn it down to build it right.


Prerequisites

Next Steps

Deep Dives