Part 1 of the Journey: The Chaos Era - The Problem & The Pain Previous: Copy-Paste Architecture | Next: The Logger Revolution
ESM/CommonJS Migration Hell: When Bitcoin Testnet Broke Everything
The definitive guide to debugging MCP module compatibility issues
Historical Context (November 2025): Crisis occurred September 16, 2025, during manual server development phase. We were navigating Node.js module system complexities before MCP-specific solutions emerged. This debugging story remains relevant for understanding module compatibility in MCP servers.
Date: October 7, 2025 (Story from September 16, 2025) Author: Myron Koch & Claude Desktop Category: MCP Debugging, TypeScript, Node.js Modules Reading Time: 18 minutes
The Crisis at 13/14
You know that feeling when you build something beautiful, everything’s working perfectly, and then you add one more piece and the entire thing collapses?
September 16, 2025. I’m reviewing my blockchain MCP ecosystem. Fourteen servers total. Thirteen of them responding beautifully to Claude Desktop:
✅ Ethereum Sepolia - 9.2M blocks indexed
✅ Polygon Mainnet - 76.5M blocks, 53.5 TPS
✅ Arbitrum Mainnet - 379M+ blocks
✅ Avalanche C-Chain - 68.8M blocks
✅ Cosmos Hub - 27.5M blocks
✅ BNB Chain - 61.3M blocks
✅ Solana Testnet - 358M slots
✅ XRP Ledger
✅ NEAR Protocol
✅ Osmosis
✅ Sui Network
✅ Base Sepolia
✅ Cloudflare Workers
And then there’s Bitcoin testnet:
❌ Won’t load
❌ Claude Desktop logs full of errors
❌ Every tool invocation fails
❌ Complete radio silence
Thirteen working servers. One completely dead. All using the same architecture, same patterns, same copy-paste structure.
What makes one server different?
This is the story of how three missing configuration lines can bring down an entire blockchain server, and why “ESM/CommonJS compatibility” is the phrase that should strike fear into every Node.js developer’s heart.
Act I: The Error Messages
The First Clue
I check Claude Desktop’s logs (~/Library/Logs/Claude/mcp*.log):
[bitcoin-testnet] ReferenceError: exports is not defined in ES module scope
[bitcoin-testnet] This file is being treated as an ES module because it has a .js extension
[bitcoin-testnet] and package.json contains "type": "module"
[bitcoin-testnet] To treat it as a CommonJS script, rename it to use the .cjs extension
Wait. “exports is not defined”? But I’m not using exports anywhere. I’m writing TypeScript with proper ESM imports:
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
export const server = new Server(/* ... */);
How is exports even in the picture?
The Second Error
I try to start the server manually:
$ node dist/index.js
Error [ERR_REQUIRE_ESM]: require() of ES Module /node_modules/@modelcontextprotocol/sdk/server/index.js not supported.
Instead change the require of index.js in /dist/index.js to a dynamic import() which is available in all CommonJS modules.
Now it’s telling me I’m using require() on an ES module. But I’m not using require() at all!
$ grep -r "require(" dist/
dist/index.js:const sdk_1 = require("@modelcontextprotocol/sdk/server/index.js");
There it is.
My TypeScript source has import statements, but my compiled JavaScript output has require() calls.
TypeScript is betraying me.
The Third Error
For completeness, I check the MCP SDK:
$ npm list @modelcontextprotocol/sdk
bitcoin-testnet-mcp-server@1.0.0
└── @modelcontextprotocol/sdk@0.5.0
Version 0.5.0.
I check one of the working servers:
$ cd ../ethereum-sepolia-mcp-server
$ npm list @modelcontextprotocol/sdk
ethereum-sepolia-mcp-server@1.0.0
└── @modelcontextprotocol/sdk@1.0.3
Version 1.0.3.
Three problems. Three different layers. All connected.
Act II: The Investigation
Comparing Configurations
I pull up package.json from a working server (Ethereum Sepolia) and the broken server (Bitcoin testnet) side by side.
Ethereum (Working):
{
"name": "ethereum-sepolia-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3"
}
}
Bitcoin (Broken):
{
"name": "bitcoin-testnet-mcp-server",
"version": "1.0.0",
"main": "./dist/index.js",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0"
}
}
Missing line: "type": "module"
The TypeScript Configuration
Next, tsconfig.json:
Ethereum (Working):
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
}
}
Bitcoin (Broken):
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src"
}
}
Wrong setting: "module": "commonjs" instead of "ESNext"
The Pattern
Every working server has:
- ✅
"type": "module"in package.json - ✅
"module": "ESNext"in tsconfig.json - ✅ SDK version ^1.0.0 or higher
Bitcoin testnet has:
- ❌ No
"type": "module" - ❌
"module": "commonjs" - ❌ SDK version 0.5.0
Three missing pieces. Complete failure.
Act III: Understanding the Enemy
Before we fix this, we need to understand what we’re fighting. This isn’t just a configuration error - it’s a fundamental clash between two module systems that were never meant to coexist.
A Brief History of JavaScript Modules
In the Beginning: No Modules
Early JavaScript (1995-2009):
// Everything in global scope
var myFunction = function() { /* ... */ };
var myData = { /* ... */ };
Every variable was global. Every script could see every other script’s variables. It was chaos.
Then: CommonJS (2009)
Node.js introduced CommonJS modules:
// module.js
const something = require('./other.js');
module.exports = { myFunction, myData };
// Using it
const myModule = require('./module.js');
Synchronous loading. Perfect for servers, terrible for browsers.
The Standard: ECMAScript Modules (2015)
ES6 introduced official JavaScript modules:
// module.js
import { something } from './other.js';
export { myFunction, myData };
// Using it
import { myFunction } from './module.js';
Asynchronous loading. Works everywhere. The future.
The Problem: Compatibility
Node.js has to support both. Forever. Because billions of lines of CommonJS code exist.
So we get this nightmare scenario:
- ES modules can import CommonJS modules (mostly)
- CommonJS modules cannot require() ES modules
- Node.js needs to know which system you’re using
- TypeScript can compile to either format
- If these don’t align… boom
The Three-Part Configuration
For MCP servers to work, three things must agree:
1. package.json: Tell Node.js you’re using ESM
{
"type": "module"
}
Without this, Node.js assumes CommonJS (for backward compatibility).
2. tsconfig.json: Tell TypeScript to output ESM
{
"compilerOptions": {
"module": "ESNext"
}
}
Without this, TypeScript outputs require()/module.exports (CommonJS).
3. MCP SDK: Use version that supports pure ESM
{
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.0"
}
}
MCP SDK 1.0.0+ is pure ESM. Version 0.5.0 tried to support both (and failed).
What Happens When They Disagree
Scenario 1: Missing “type: module”
Node.js assumes CommonJS. TypeScript outputs ESM. Node tries to load ESM as CommonJS:
Error: Cannot use import statement outside a module
Scenario 2: TypeScript outputs CommonJS, SDK is ESM
TypeScript generates require() calls. SDK only exports ESM:
Error [ERR_REQUIRE_ESM]: require() of ES Module not supported
Scenario 3: Package says ESM, TypeScript outputs CommonJS
Node expects ESM. TypeScript outputs CommonJS. Node sees exports:
ReferenceError: exports is not defined in ES module scope
This was us. Bitcoin testnet had scenario 3.
Act IV: The Fix
Step 1: Update package.json
cd bitcoin-testnet-mcp-server
Edit package.json:
{
"name": "bitcoin-testnet-mcp-server",
"version": "1.0.0",
"type": "module", // ← ADD THIS
"main": "./dist/index.js",
"bin": {
"bitcoin-testnet-mcp-server": "./dist/index.js"
},
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('./dist/index.js', '755')\"",
"prepare": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3", // ← UPDATE THIS (was 0.5.0)
"axios": "^1.7.7",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.7.0"
}
}
Step 2: Update tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext", // ← CHANGE FROM "commonjs"
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
Step 3: Rebuild
# Clean old build
rm -rf dist/ node_modules/
# Fresh install
npm install
# Build
npm run build
Step 4: Verify the Output
Check what TypeScript generated:
$ head -20 dist/index.js
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
✅ import statements (not require())
✅ .js extensions on imports
✅ No exports variable
Perfect.
Step 5: Test
# Try running directly
$ node dist/index.js
# Should start without errors
# Test with MCP Inspector
$ npx @modelcontextprotocol/inspector dist/index.js
# Check in Claude Desktop
# Add to claude_desktop_config.json and restart
The Result
Bitcoin testnet MCP server: fully operational.
50+ tools now available:
- Transaction creation and broadcasting
- UTXO management and selection
- Address generation (legacy, segwit, taproot)
- Ordinals inscription creation and queries
- BRC-20 token operations
- Lightning Network channel management
- Mining difficulty and hashrate stats
- Mempool analysis and fee estimation
- CoinJoin/PayJoin privacy features
- Stamps protocol support
- Block and transaction exploration
From zero to hero with three configuration fixes.
Act V: The Systematic Methodology
Creating the Handoff Document
I immediately created MCP_BLOCKCHAIN_DEBUGGING_HANDOFF.md with the complete fix pattern:
# MCP Blockchain Debugging Handoff
**Date**: September 16, 2025
**Status**: Bitcoin testnet FIXED, 13 servers remaining
## The Fix Pattern
For any broken MCP server with ESM/CommonJS issues:
### 1. Check Configuration
- [ ] package.json has `"type": "module"`
- [ ] tsconfig.json has `"module": "ESNext"`
- [ ] SDK version is ^1.0.0+
### 2. Apply Fixes
```bash
# Update package.json
npm install @modelcontextprotocol/sdk@latest
# Add "type": "module" to package.json
# Update tsconfig.json module to "ESNext"
# Rebuild
rm -rf dist/ node_modules/
npm install
npm run build
3. Verify
- Build succeeds without errors
- dist/index.js uses import (not require)
- MCP Inspector can load server
- Claude Desktop shows no errors
- Tools work when invoked
### The Automated Checker
I created a script to detect ESM compatibility issues:
```bash
#!/bin/bash
# esm-compatibility-check.sh
# Validates MCP server ESM configuration
SERVER_PATH="${1:-.}"
ERRORS=0
echo "╔═══════════════════════════════════════════════════╗"
echo "║ MCP Server ESM Compatibility Checker v1.0 ║"
echo "╚═══════════════════════════════════════════════════╝"
echo ""
echo "Checking: $SERVER_PATH"
echo ""
# Check 1: package.json type
echo "━━━ 1. Checking package.json 'type' field..."
if grep -q '"type": *"module"' "$SERVER_PATH/package.json" 2>/dev/null;
then
echo "✅ ESM mode enabled (\"type\": \"module\")"
else
echo "❌ MISSING: \"type\": \"module\" in package.json"
echo " → Add: \"type\": \"module\" to package.json"
ERRORS=$((ERRORS + 1))
fi
echo ""
# Check 2: MCP SDK version
echo "━━━ 2. Checking MCP SDK version..."
SDK_VERSION=$(grep -o '@modelcontextprotocol/sdk": *"[^"*"' "$SERVER_PATH/package.json" 2>/dev/null | grep -o '[0-9][^"*'])
if [ -z "$SDK_VERSION" ]; then
echo "❌ MCP SDK not found in dependencies"
ERRORS=$((ERRORS + 1))
elif [[ "$SDK_VERSION" =~ ^1\. ]] || [[ "$SDK_VERSION" =~ ^\^ ]]; then
echo "✅ SDK version $SDK_VERSION (compatible)"
else
echo "❌ SDK version $SDK_VERSION (requires 1.0.0+)"
echo " → Update: npm install @modelcontextprotocol/sdk@latest"
ERRORS=$((ERRORS + 1))
fi
echo ""
# Check 3: tsconfig.json module
echo "━━━ 3. Checking tsconfig.json 'module' setting..."
if grep -q '"module": *"ESNext"' "$SERVER_PATH/tsconfig.json" 2>/dev/null;
then
echo "✅ TypeScript module: ESNext"
elif grep -q '"module": *"ES2022"' "$SERVER_PATH/tsconfig.json" 2>/dev/null;
then
echo "✅ TypeScript module: ES2022 (compatible)"
elif grep -q '"module": *"ES2020"' "$SERVER_PATH/tsconfig.json" 2>/dev/null;
then
echo "⚠️ TypeScript module: ES2020 (may work, but ESNext recommended)"
else
MODULE_TYPE=$(grep -o '"module": *"[^"*"' "$SERVER_PATH/tsconfig.json" 2>/dev/null)
echo "❌ TypeScript module: $MODULE_TYPE (incompatible)"
echo " → Change to: \"module\": \"ESNext\""
ERRORS=$((ERRORS + 1))
fi
echo ""
# Check 4: Build output
echo "━━━ 4. Checking build output..."
if [ ! -f "$SERVER_PATH/dist/index.js" ]; then
echo "⚠️ No build output found"
echo " → Run: npm run build"
else
# Check for require() in compiled output
if grep -q 'require(' "$SERVER_PATH/dist/index.js" 2>/dev/null;
then
echo "❌ Build output contains require() calls"
echo " → TypeScript is generating CommonJS instead of ESM"
echo " → Verify tsconfig.json and rebuild"
ERRORS=$((ERRORS + 1))
else
echo "✅ Build output uses ESM imports"
fi
# Check for proper shebang
if head -n 1 "$SERVER_PATH/dist/index.js" | grep -q '#!/usr/bin/env node'; then
echo "✅ Shebang present in output"
else
echo "⚠️ Missing shebang in dist/index.js"
echo " → Add to src/index.ts: #!/usr/bin/env node"
fi
fi
echo ""
# Check 5: Source code patterns
echo "━━━ 5. Checking source code for CommonJS patterns..."
if grep -r 'require(' "$SERVER_PATH/src/" 2>/dev/null | grep -v "node_modules" | grep -v ".spec." | grep -v ".test."; then
echo "❌ Found require() in source code"
echo " → Convert to: import { X } from 'Y'"
ERRORS=$((ERRORS + 1))
else
echo "✅ No require() found in source"
fi
if grep -r 'module\.exports' "$SERVER_PATH/src/" 2>/dev/null | grep -v "node_modules"; then
echo "❌ Found module.exports in source code"
echo " → Convert to: export { X }"
ERRORS=$((ERRORS + 1))
else
echo "✅ No module.exports found in source"
fi
echo ""
# Summary
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
if [ $ERRORS -eq 0 ]; then
echo "✅ ALL CHECKS PASSED"
echo ""
echo "This MCP server is properly configured for ESM."
exit 0
else
echo "❌ FOUND $ERRORS ISSUE(S)"
echo ""
echo "Fix the issues above and re-run this checker."
exit 1
fi
Make it executable:
chmod +x esm-compatibility-check.sh
Use it on any MCP server:
./esm-compatibility-check.sh /path/to/mcp-server
The Minimal Working Template
For Future MCP Servers
Here’s the exact configuration that works for every blockchain MCP server:
package.json:
{
"name": "your-mcp-server",
"version": "1.0.0",
"type": "module",
"main": "./dist/index.js",
"bin": {
"your-mcp-server": "./dist/index.js"
},
"scripts": {
"build": "tsc && node -e \"require('fs').chmodSync('./dist/index.js', '755')\"",
"prepare": "npm run build",
"watch": "tsc --watch"
},
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20.0.0",
"typescript": "^5.7.0"
},
"engines": {
"node": ">=18.0.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}
src/index.ts:
#!/usr/bin/env node
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
CallToolRequestSchema,
ListToolsRequestSchema,
} from '@modelcontextprotocol/sdk/types.js';
import { z } from 'zod';
// Define your tools
const YOUR_TOOL_SCHEMA = z.object({
param: z.string().describe('Parameter description'),
});
// Create server
const server = new Server(
{
name: 'your-mcp-server',
version: '1.0.0',
},
{
capabilities: {
tools: {},
},
}
);
// List tools handler
server.setRequestHandler(ListToolsRequestSchema, async () => {
return {
tools: [
{
name: 'your_tool',
description: 'Description of what your tool does',
inputSchema: {
type: 'object',
properties: {
param: {
type: 'string',
description: 'Parameter description',
},
},
required: ['param'],
},
},
],
};
});
// Call tool handler
server.setRequestHandler(CallToolRequestSchema, async (request) => {
const { name, arguments: args } = request.params;
try {
if (name === 'your_tool') {
const parsed = YOUR_TOOL_SCHEMA.parse(args);
// Your tool logic here
const result = { message: 'Success' };
return {
content: [
{
type: 'text',
text: JSON.stringify(result, null, 2),
},
],
};
}
throw new Error(`Unknown tool: ${name}`);
} catch (error) {
if (error instanceof z.ZodError) {
throw new Error(
`Invalid arguments: ${error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')}`
);
}
throw error;
}
});
// Start server
async function main() {
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('MCP server running on stdio');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
Key points:
- ✅
.jsextensions on all imports (required for ESM) - ✅ Shebang at top of index.ts
- ✅ Proper error handling with Zod
- ✅ TypeScript types throughout
- ✅ Async/await patterns
Deep Dive: ESM vs CommonJS
The Technical Details
ESM Loading Process:
- Parse: Analyze module structure, find imports/exports
- Download: Fetch all dependencies (can be async)
- Link: Connect imports to exports across modules
- Evaluate: Run module code top-to-bottom
CommonJS Loading Process:
- Execute: Run module code immediately
- Cache: Store exports in module cache
- Return: Hand exports back to requiring module
Key Differences:
| Feature | ESM | CommonJS |
|---|---|---|
| Syntax | import/export | require()/module.exports |
| Loading | Asynchronous | Synchronous |
| Static | Yes (analyzed at parse time) | No (dynamic at runtime) |
| Top-level await | ✅ Supported | ❌ Not supported |
| Tree shaking | ✅ Excellent | ⚠️ Limited |
| Conditional imports | ⚠️ Complex | ✅ Easy |
| File extensions | Required | Optional |
Why MCP SDK Went Pure ESM
MCP SDK 0.5.0:
- Tried to support both ESM and CommonJS
- Complex build process
- Compatibility hacks everywhere
- Still broke in edge cases
MCP SDK 1.0.0:
- Pure ESM only
- Simpler codebase
- Better tree shaking
- Modern JavaScript features
- Clear error messages
The trade-off:
- ❌ Can’t use in CommonJS projects
- ✅ Much better DX for ESM users
- ✅ Future-proof
- ✅ Smaller bundles
TypeScript’s Role
TypeScript is module-agnostic. It doesn’t care whether you use ESM or CommonJS. It just compiles your code to whatever you specify:
// Your TypeScript source (same either way)
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
export const server = new Server(/*...*/);
With “module”: “commonjs”:
// Compiled output
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
const server_1 = require("@modelcontextprotocol/sdk/server/index.js");
exports.server = new server_1.Server(/*...*/);
With “module”: “ESNext”:
// Compiled output
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
export const server = new Server(/*...*/);
If package.json says ESM but TypeScript outputs CommonJS:
Node.js sees:
- “This is an ES module” (from package.json)
- But file contains:
exports,require(),module.exports - Error: “These aren’t valid in ESM!”
That was our bug.
Common Pitfalls & Solutions
Pitfall 1: Forgetting .js Extensions
ESM requires explicit file extensions in imports:
// ❌ This breaks
import { helper } from './utils';
// ✅ This works
import { helper } from './utils.js';
Why: ESM needs to know file type for loading. No extension = Node doesn’t know what to load.
TypeScript quirk: Even though your source is .ts, imports must use .js (TypeScript strips the extension during compilation).
Pitfall 2: Mixed Import Styles
// ❌ Don't mix these
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
const fs = require('fs'); // BREAKS IN ESM
// ✅ Use all imports
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
import fs from 'fs';
Pitfall 3: Dynamic Require
// ❌ This CommonJS pattern breaks
const moduleName = 'some-module';
const module = require(moduleName);
// ✅ Use dynamic import (async)
const moduleName = 'some-module';
const module = await import(moduleName);
Pitfall 4: __dirname and __filename
These global variables don’t exist in ESM:
// ❌ Breaks in ESM
console.log(__dirname);
// ✅ ESM equivalent
import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
Pitfall 5: JSON Imports
// ❌ Old way (CommonJS)
const package = require('./package.json');
// ✅ ESM way (requires tsconfig setting)
import package from './package.json' assert { type: 'json' };
// Or use fs
import { readFileSync } from 'fs';
const package = JSON.parse(readFileSync('./package.json', 'utf-8'));
Testing Your Configuration
The Complete Checklist
Use this to verify any MCP server:
Configuration:
-
package.jsoncontains"type": "module" -
tsconfig.jsonhas"module": "ESNext"(or “ES2022”) - MCP SDK version is ^1.0.0 or higher
- All imports in source code use
.jsextensions - No
require()ormodule.exportsin source
Build Process:
-
npm installcompletes without errors -
npm run buildsucceeds -
dist/index.jsexists and has correct shebang -
dist/index.jsis executable (755 permissions) - Build output uses
import(notrequire())
Runtime:
-
node dist/index.jsstarts without errors - MCP Inspector can load the server
- Claude Desktop logs show no ESM errors
- Tools list appears correctly
- Tool invocations work
Integration:
- Server responds to list_tools request
- Server responds to call_tool request
- Errors are properly formatted
- Server cleans up on shutdown
Manual Testing Commands
# 1. Check configuration
grep -E '"type"|"module"' package.json tsconfig.json
# 2. Verify SDK version
npm list @modelcontextprotocol/sdk
# 3. Check build output
head -20 dist/index.js
# 4. Look for CommonJS patterns
grep -E "require\(|module\.exports" dist/index.js
# 5. Test with MCP Inspector
npx @modelcontextprotocol/inspector dist/index.js
# 6. Check Claude Desktop config
cat ~/Library/Application\ Support/Claude/claude_desktop_config.json
# 7. Restart Claude Desktop and check logs
tail -f ~/Library/Logs/Claude/mcp*.log
Lessons Learned
For MCP Development
1. Start with the right template
Don’t copy-paste from old projects. Use the official MCP SDK templates or create your own verified template.
2. Configuration drift is silent
You won’t notice until something breaks. Use automated checkers.
3. Error messages are misleading
“exports is not defined” doesn’t mean you used exports. It means Node.js expected ESM but got CommonJS.
4. Compare working examples
When debugging, find something that works and compare configurations line-by-line.
5. Document your fixes
Future you (or your team) will thank you. Create handoff documents with exact steps.
For TypeScript Projects
1. Module setting is critical
"module": "commonjs" vs "module": "ESNext" completely changes output format.
2. Package.json and tsconfig.json must agree
If package says ESM, TypeScript must output ESM.
3. .js extensions are mandatory in imports
Even in .ts files, even though it feels wrong.
4. Test your build output
Don’t assume TypeScript did what you expected. Check the compiled JavaScript.
For Debugging
1. Look at compiled output first
Source code might be correct, but compilation might be wrong.
2. Check versions systematically
Old SDK versions, old Node.js versions, old TypeScript versions - all can cause issues.
3. Create reproducible test cases
Minimal MCP server that demonstrates the issue.
4. Use automated tooling
Scripts like our ESM checker catch issues before they break production.
The Ecosystem Impact
After fixing Bitcoin testnet, I had a decision: apply this pattern to the remaining 9 blockchain servers immediately, or document first?
I chose documentation.
Why? Because this will happen again:
- New team members will copy old configurations
- Dependencies will update and break compatibility
- Some servers will work, others won’t
- Nobody will remember why
The MCP_BLOCKCHAIN_DEBUGGING_HANDOFF.md document is now the source of truth:
# Broken MCP Server? Start Here.
1. Run esm-compatibility-check.sh
2. Fix any issues it identifies
3. Rebuild: rm -rf dist/ node_modules/ && npm install && npm run build
4. Test with MCP Inspector
5. Verify in Claude Desktop
6. Mark server as verified in this document
## Verified Servers (14/14)
- [x] claude-memory
- [x] desktop-commander
- [x] filesystem
- [x] ethereum-sepolia
- [x] bitcoin-testnet ← JUST FIXED
- [ ] polygon-mainnet ← NEXT
- [ ] arbitrum-mainnet
- [ ] avalanche-fuji
- [ ] bsc-testnet
- [ ] base-sepolia
- [ ] solana-devnet
- [ ] xrp-testnet
- [ ] near-testnet
- [ ] osmosis-testnet
Systematic. Repeatable. Documentable.
The Fix Template
Quick Reference
For any broken MCP server:
#!/bin/bash
# fix-esm-compatibility.sh
# 1. Update package.json
npm pkg set type=module
npm install @modelcontextprotocol/sdk@latest
# 2. Update tsconfig.json
# (manually edit "module": "ESNext")
# 3. Clean rebuild
rm -rf dist/ node_modules/
npm install
npm run build
# 4. Verify
node dist/index.js &
PID=$!
sleep 2
kill $PID
echo "Fixed! Test with: npx @modelcontextprotocol/inspector dist/index.js"
Before/After Example
Before (Broken):
package.json:
{
"name": "my-mcp-server",
"dependencies": {
"@modelcontextprotocol/sdk": "^0.5.0"
}
}
tsconfig.json:
{
"compilerOptions": {
"module": "commonjs"
}
}
Result:
Error [ERR_REQUIRE_ESM]: require() of ES Module not supported
After (Working):
package.json:
{
"name": "my-mcp-server",
"type": "module",
"dependencies": {
"@modelcontextprotocol/sdk": "^1.0.3"
}
}
tsxconfig.json:
{
"compilerOptions": {
"module": "ESNext"
}
}
Result:
✅ MCP server running on stdio
Conclusion
The most frustrating bugs are configuration mismatches.
Not your code. Not the SDK. Not Node.js being weird.
Just three settings that need to agree:
- package.json: Tell Node.js you’re using ESM
- tsconfig.json: Tell TypeScript to output ESM
- SDK version: Use one that supports pure ESM
Get those three right, and everything works perfectly.
Get any one wrong, and nothing works at all.
No graceful degradation. No partial functionality. Just complete failure.
This is why systematic debugging matters:
- Compare configurations between working and broken
- Identify the differences
- Apply fixes systematically
- Document for next time
- Create automated checkers
The Bitcoin testnet took 6 hours to debug the first time.
With this guide, it takes 5 minutes.
That’s the power of documentation.
Now go fix your broken MCP servers. The checklist is waiting. 🚀
Additional Resources
Official Documentation
Tools & Scripts
- ESM Compatibility Checker: View script above
- MCP Inspector:
npx @modelcontextprotocol/inspector - Claude Desktop Logs:
~/Library/Logs/Claude/mcp*.log
Related Blog Posts
- Blog Post 018: Router DDoS Crisis (systematic debugging)
- Blog Post 017: Memory Management in MCP (architecture patterns)
- Blog Post 015: TypeScript Migration Journey
Technical Tags
esm commonjs mcp-debugging typescript nodejs module-compatibility configuration-management systematic-debugging blockchain-mcp
Metadata
Word Count: ~7,200 words
Code Blocks: 25+
Scripts: 2 (checker + fixer)
Reading Time: 30-35 minutes
Skill Level: Intermediate to Advanced
Prerequisites: TypeScript, Node.js, MCP SDK basics
Next in Series: Blog Post 020 - Vector Embeddings + Ollama: Semantic Search Revolution
Previous in Series: Blog Post 018 - Router DDoS Blacklist Crisis
Hit this issue? Found another edge case? Share your experiences in the comments. Let’s build the definitive ESM/CommonJS compatibility guide together.
Questions? Find me on GitHub or LinkedIn
Related Reading
Prerequisites
- Copy-Paste Architecture: When Duplication Beats Abstraction - Understand the architecture this module system lives in.
Next Steps
- The Logger Revolution: Why console.log Will Kill Your MCP Server - After fixing module issues, the next silent killer is improper logging.
Deep Dives
- The TypeScript Migration Journey - The broader context for why we were dealing with these module issues in the first place.
- The MCP Inspector Deep Dive: Your Only Debugging Friend - The essential tool for debugging these kinds of silent failures.