Turning a web tool into a zero-dependency MCP server

I run DomainIntel, a small web app that analyzes any The answer was the Model Context Protocol npx -y @domainintel/mcp — a single file with no runtime dependencies. MCP servers expose "tools" an agent can call. I wrapped each analyzer as one: whois_lookup, dns_records, ssl_certificate, security_headers, domain_reputation, subdomain_discovery, and a full_domain_report that runs domain and returns The interesting part wasn't the MCP code. It was packaging. npx with no install friction I wanted a u
I run DomainIntel, 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 do I 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 I 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. I 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 ~150 lines on top of the existing analyzers
and the MCP SDK's stdio transport.
The interesting part wasn't the MCP code. It was packaging.
Goal: npx with no install friction
I wanted a user to run npx -y @domainintel/mcp and have it work — no clone, no
npm install of a dependency tree. That means bundling the server and the
analyzer graph it reuses into one self-contained file. I used esbuild. Four
things bit me; all are general to "bundle a Node CLI that reuses CommonJS code
into an ESM binary."
1. createRequire so a bundler can follow your imports
My server originally pulled the analyzers in with createRequire:
const require = createRequire(import.meta.url);
const { analyzeDns } = require('../lib/analyzers/dns');
Enter fullscreen mode Exit fullscreen mode
esbuild can't 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;
Enter fullscreen mode Exit fullscreen mode
Now esbuild follows the graph, and node server.mjs still works in dev.
2. Node built-ins under ESM output: "Dynamic require of 'net' is not supported"
One dependency (whois) calls require('net') internally. In esbuild's ESM
output there's no require, so it throws at runtime. 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')
}
Enter fullscreen mode Exit fullscreen mode
3. stdout belongs to the protocol — don't log to it
MCP over stdio uses stdout for the JSON-RPC stream. My analyzers' shared logger
wrote to a logs/ directory, which is also wrong for a globally-installed CLI
(it would try to mkdir inside the npm install dir). I 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 }));
Enter fullscreen mode Exit fullscreen mode
4. The small stuff
-
Two shebangs. The entry file had
#!/usr/bin/env nodeand the banner added one, so the bundle's line 2 was an invalid#. Drop it from the source. -
"type": "module"+ CommonJS. When I vendored the analyzers into a standalone repo whosepackage.jsonhad"type": "module", esbuild treated the CJS.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
mcp/dist/server.mjs is one ~1.7 MB file, zero runtime dependencies. Install:
claude mcp add domainintel -- npx -y @domainintel/mcp
Enter fullscreen mode Exit fullscreen mode
Then your agent can run "give me a full report on stripe.com" and get
structured results instead of shelling out to dig/whois and parsing text.
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. Source is on GitHub —
happy to answer questions about any of the above.


