MCP Servers
7

What is a Node.js program when it isn't a server?

Nodejs without server.listen - is it of any good?

April 30, 2026

Pop quiz. Your favourite text editor is open, you've just typed npm init -y, and you're about to start writing the next thing. What's the first non-trivial line of code that ends up in index.js?

For me — and for almost every Node developer I know — the answer is some version of:

js
import express from "express";
const app = express();
// ...
app.listen(3000);

That last line. app.listen(). The thing at the bottom of every tutorial. The thing that says "this Node program is a server now." It's so deeply baked in that I've watched senior devs typo it (app.lisnet), squint at the error, fix it, and move on without ever consciously asking why it's there.

Let's ask why it's there. Or rather — let's see what Node looks like when it isn't there. Because the next thing in this series is an MCP server, and an MCP server is, by default, a program (use whatever programming language you like, we'll do it in Nodejs) with no listen() call at the bottom. No port. No HTTP. No express. And if your hands are wired to expect that line, the whole shape of the thing feels wrong until you reframe what a Node program actually is.

This post is the reframe. No MCP yet. Just Node, with the server taken out, so that when we put MCP back in next time it lands in a brain that has room for it.

The default mental model

Here's the picture most of us hold, often without examining it:

Node.js is a JavaScript runtime that lets me build web servers. We write some routes, set up some middleware, call listen(), and now we have a backend.

That's not wrong. It's just narrow. It's the same as saying "Python is a language for writing Django apps." Technically a Django app is a Python program, but Python isn't for Django — Django is one of an infinite number of things you can build with Python. Same with Node.

The cleaner version of the model:

Node.js is a way to run JavaScript outside the browser. It gives you a process (here's an overview on mobile processes - same mental model applies to regular, pc processes), an event loop (just like Dart's event loop), file system access, network access, child-process management, and a module system. What you build with that is up to you. A web server is the most common shape, not the only shape.

Let's phrase it this way: Node is a program that runs JavaScript. Whatever JavaScript you put into it is what runs. If that JavaScript happens to call http.createServer().listen(3000), you have a web server. If it doesn't, you have... something else. We'll spend the rest of this post on what something else can be.

A Node program is a process

When you type node index.js in a terminal, the operating system starts a process. That process happens to be node, with index.js loaded into V8. From the OS's perspective, it's no more special than cat, grep, ls, or psql. It's a process. It has a PID (process ID). It has stdin, stdout, and stderr. It can read files, write files, open sockets, spawn other processes, exit with a status code. Same toolkit cat has. Same toolkit grep has.

The difference between your Node program and cat is purely what it does with that toolkit. cat reads bytes from stdin (or a file) and writes them to stdout. Your Node program could do the exact same thing in three lines:

js
process.stdin.on("data", (chunk) => {
  process.stdout.write(chunk);
});

Run node passthrough.js and pipe something in: echo "hello" | node passthrough.js. Output: hello. You just wrote a worse version of cat in JavaScript. No app.listen() in sight. Still a Node program. Still useful in the right context.

If that example feels weirdly basic, that's the point. Strip away the framework reflexes and Node is small. The framework reflexes are doing a lot of work to convince us otherwise.

Shapes a Node program can take

Once you accept that Node is a runtime and not a web framework, you start noticing the variety of shapes Node programs already take in the wild. Most developers use these tools daily without noticing they're Node:

CLI tools. npm, npx, prettier, eslint, tsc, vite, webpack, next, playwright. All Node programs. None of them call app.listen() (well, vite dev does at one point, but that's an internal detail of the dev experience — vite build doesn't, and shipping the actual product doesn't). They take input from your terminal, do work, write output, exit. Just like gcc or find or any Unix tool, except the implementation language is JavaScript.

Build pipelines. When you run npm run build, what's running is a chain of Node processes orchestrating other Node processes — TypeScript compiles, then Rollup or esbuild bundles, then maybe a post-processor minifies, then maybe a deploy step uploads. Each one a process. Each one started, doing its job, ending. The "server" is nowhere in sight; the work is sequential, finite, and ends with an exit code.

Stdio-driven services. This is the one that matters most for MCP. A Node program that runs as a child of some other process, takes input on stdin, writes output on stdout, and exits when its parent exits. No port. No public address. Just a pipe between two processes. We'll come back to this in a minute.

Schedulers and one-shots. A Node script triggered by cron, a GitHub Action, an AWS Lambda. Starts up, does one job, dies. Most "serverless" Node code is closer to a CLI tool than to an Express app — the frame around it (Lambda, Cloud Run) is what makes it look HTTP-shaped, but the user-written part is often just a function that returns a value.

Tooling embedded in editors. Your TypeScript language server, the thing that makes VS Code show squiggles, is a Node program. It runs as a child process of the editor. It speaks Language Server Protocol over stdin/stdout. It doesn't have a port. It has a parent.

Custom orchestrators. A Node program that spawns ffmpeg, watches it, parses its output, decides whether to spawn another ffmpeg with different flags, finally writes a manifest. That entire workflow can live in 200 lines of Node and never open a single socket.

This is the surface area the Express tutorial doesn't show you. Once you start looking, half the developer tools you use are Node programs that aren't servers.

Stdin, stdout, stderr — the IPC nobody talks about as IPC

If you've spent your career writing servers, you treat HTTP as the canonical way to get information into and out of a program. Want to send some data? POST it. Want to receive some? GET it. Two endpoints, a JSON body, done.

Stdin and stdout are the same idea, except they pre-date HTTP by about thirty years and don't need a port.

Three streams come pre-attached to every Unix process, including every Node process:

  • stdin — bytes flowing in. The process reads from it.
  • stdout — bytes flowing out. The process writes to it.
  • stderr — also bytes flowing out, but a separate stream. Convention: data on stdout, diagnostics on stderr.

In a terminal session, stdin is your keyboard, stdout and stderr are both your screen. But under the hood they're just file descriptors — number 0, 1, and 2 respectively — that the parent process can connect to anything: a file, a pipe to another process, a socket, a TTY, /dev/null.

When you type cat data.txt | grep error | wc -l, the shell:

  1. Spawns cat with stdin from data.txt and stdout connected to a pipe.
  2. Spawns grep error with stdin from that pipe and stdout connected to a second pipe.
  3. Spawns wc -l with stdin from that pipe and stdout connected to your terminal.

Three processes. Three pipes between them. No HTTP, no port, no listen(). Inter-process communication has been a solved problem since 1973, and it's been hiding in plain sight inside | ever since.

Node speaks this protocol natively. process.stdin is a Readable stream. process.stdout and process.stderr are Writable streams. You don't have to install anything. You don't have to require anything. They're already there, plugged into whatever the parent process connected them to.

That's the entire foundation underneath what comes later in this series. An MCP server, in its most common form, is a Node program that talks to its parent over stdin and stdout. Same channel cat uses. Same channel grep uses. The parent in MCP's case is your editor or desktop app instead of bash, but the wire is identical.

Child processes the other way

You can also be the parent. Node has a child_process module that lets your program spawn other programs and treat them the way the shell treats cat and grep — as processes you own, with stdin/stdout/stderr you can read and write.

js
import { spawn } from "node:child_process";

const ffmpeg = spawn("ffmpeg", ["-i", "input.mp4", "output.webm"]);

ffmpeg.stdout.on("data", (chunk) => console.log("[ffmpeg]", chunk.toString()));
ffmpeg.stderr.on("data", (chunk) => console.error("[ffmpeg err]", chunk.toString()));
ffmpeg.on("exit", (code) => console.log("done with code", code));

That's it. Your Node program is now driving ffmpeg. You can pipe things into it, listen to its output, kill it if it takes too long, spawn another one if the first one fails. None of this involves HTTP. None of it involves a port. You're using the OS's process model directly.

This is what the Flutter pipeline MCP server I built does, ultimately — it spawns flutter test, parses the JSON it streams to stdout, returns a structured summary. The MCP server itself is a Node program, sitting between the agent (above) and the Flutter toolchain (below), translating between two protocols. It's processes all the way down.

So what is a Node program, really

Stripping the framework habit away:

A Node program is a JavaScript-driven process. It can read input — from arguments, environment variables, stdin, files, sockets, pipes from sibling processes. It can do work — compute, fetch, write to disk, spawn children. It can produce output — to stdout, stderr, files, sockets, return codes. When it's done, it exits.

A web server is one configuration of that. It happens to listen on a socket, never voluntarily exit, and treat HTTP as its input/output protocol. Useful, common, but not definitional. Take the socket and the HTTP framing away and you still have a Node program. Add stdin and a parent process and you have a stdio service. Add argv parsing and an exit code and you have a CLI. Add a child_process.spawn call and you have an orchestrator.

The Express habit tricks us into thinking the server is the thing. The server is one of the things. It's the loudest one because it's the one tutorials lead with and the one job interviews ask about, but the runtime is bigger than the framework.

Why this matters for what's coming

The pillar post in this series builds an MCP server. When you read "MCP server," your reflex is going to be the same one I had on day one — picture an Express app, a port, some routes. Strip that picture. The MCP server we're going to build is closer to cat. It's a Node program that:

  • Starts when its parent (Claude Desktop, Cursor, your editor) spawns it.
  • Reads JSON-RPC messages from stdin.
  • Writes JSON-RPC responses to stdout.
  • Logs diagnostics to stderr (because stdout is the wire — drop a console.log in by mistake and you've corrupted the protocol).
  • Exits when the parent closes the pipe.

If your mental model still has app.listen(3000) in it, that shape is going to feel wrong. Where's the port? How do I curl it? Where does it log to? Those are framework questions, not Node questions. Node has had stdin and stdout since v0.1. The MCP server is just using them.

This is the honest reason MCP servers feel weirdly invisible to people coming from a web background. Not because they're hard, but because they sit in a part of Node that the Express tutorial pipeline never visits. Once you've been there once — once you've written a Node program that reads stdin, does something, writes to stdout, and that's the whole program — MCP servers stop being mysterious. They're just one specific dialect of the conversation that processes have been having with each other since Unix shipped.

Next post in the series: the wrong picture of where intelligence lives in an MCP system. The MCP server is the dumbest piece of the stack, on purpose, and that's not a flaw.

Related Topics

nodejs without expressnodejs clinodejs processwhat is nodejs really

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.