Turning a Web Tool into a Zero-Dependency MCP Server
DomainIntel is a small web app that analyzes any domain: WHOIS, DNS, SSL/TLS, HTTP security headers, blocklist reputation, and subdomain discovery. The analysis engine was already there as a set of Node modules behind an Express API. The question was how to let AI agents use it directly, without standing up a whole new service.
The answer was the Model Context Protocol (MCP). This post is the practical, gotcha-heavy version of how we shipped it as npx -y @domainintel/mcp, a single file with no runtime dependencies.
The shape of it
MCP servers expose "tools" an agent can call. We wrapped each analyzer as one: whois_lookup, dns_records, ssl_certificate, security_headers, domain_reputation, subdomain_discovery, and a full_domain_report that runs everything and returns an overall score. Each takes a domain and returns structured JSON. The whole server is about 150 lines on top of the existing analyzers and the MCP SDK's stdio transport.
The interesting part was not the MCP code. It was packaging.
Goal: npx with no install friction
We wanted a user to run npx -y @domainintel/mcp and have it work, with no clone and no npm install of a dependency tree. That means bundling the server and the analyzer graph it reuses into one self-contained file. We used esbuild. Four things bit us, and all of them are general to the task of bundling a Node CLI that reuses CommonJS code into an ESM binary.
1. createRequire so a bundler can follow your imports
The server originally pulled the analyzers in with createRequire:
const require = createRequire(import.meta.url);
const { analyzeDns } = require('../lib/analyzers/dns');
esbuild cannot follow a runtime createRequire call. It only sees static imports, so nothing got bundled. The fix was to switch to static default imports, which Node's ESM loader maps to a CommonJS module's module.exports:
import dnsPkg from '../lib/analyzers/dns.js';
const { analyzeDns } = dnsPkg;
Now esbuild follows the graph, and node server.mjs still works in dev.
2. Node built-ins under ESM output
One dependency (whois) calls require('net') internally. In esbuild's ESM output there is no require, so it throws at runtime with "Dynamic require of 'net' is not supported". The fix is a banner that defines one via createRequire, which esbuild's shim then uses for built-ins:
banner: {
js: [
'#!/usr/bin/env node',
"import { createRequire as __cr } from 'module';",
'const require = __cr(import.meta.url);'
].join('\n')
}
3. stdout belongs to the protocol
MCP over stdio uses stdout for the JSON-RPC stream. Our analyzers' shared logger wrote to a logs/ directory, which is also wrong for a globally-installed CLI, since it would try to mkdir inside the npm install directory. We swapped it at build time for a stderr-only stub using an esbuild onResolve plugin, so the real app keeps file logging and the bundle stays quiet on stdout:
build.onResolve({ filter: /utils[\\/]errorLogger(\.js)?$/ }, () => ({ path: stub }));
4. The small stuff
- Two shebangs. The entry file had
#!/usr/bin/env nodeand the banner added one, so the bundle's second line was an invalid#. Drop it from the source and let the banner own it. "type": "module"plus CommonJS. When we vendored the analyzers into a standalone repo whosepackage.jsonhad"type": "module", esbuild treated the CommonJS.jsfiles as ESM and choked onmodule.exports. Removing"type": "module"(the.mjsentry stays ESM by extension) fixed it.- A dependency going ESM.
[email protected]switched to ESM, which broke the non-bundledrequire('whois')dev path. Pinning to2.15.0kept both the dev path and a reproducible bundle.
Result
The bundle is one file of roughly 1.7 MB with zero runtime dependencies. Install it into Claude with:
claude mcp add domainintel -- npx -y @domainintel/mcp
Then your agent can run a request like "give me a full report on stripe.com" and get structured results back, instead of shelling out to dig and whois and parsing text. The same engine is documented on the MCP server guide, and the source is on GitHub.
If you maintain a tool with a usable core, wrapping it as an MCP server is a small lift, and bundling it to a single file makes it genuinely one command to adopt. We later did the same thing for the terminal, which is the CLI story.