DEV Community

Chris Morris
Chris Morris

Posted on • Originally published at domainintel.app

Turning a web tool into a zero-dependency MCP server

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 node and 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 whose package.json had "type": "module", esbuild treated the CJS .js files as ESM and choked on module.exports. Removing "type": "module" (the .mjs entry stays ESM by extension) fixed it.
  • A dependency going ESM. whois@2.16 switched to ESM, which broke the non-bundled require('whois') dev path. Pinning to 2.15.0 kept 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.

Top comments (0)