fix: pre-flight port check and graceful shutdown to prevent EADDRINUSE#1091
fix: pre-flight port check and graceful shutdown to prevent EADDRINUSE#1091btbowman wants to merge 2 commits intomodelcontextprotocol:mainfrom
Conversation
…INUSE race condition Three bugs caused the Inspector to report "PORT IS IN USE" even when ports were free, and to leave ports in CLOSE_WAIT after Ctrl+C: 1. No pre-flight port availability check — both the client (client.js) and server (index.ts) attempted to bind directly without first verifying the port was free, leading to confusing EADDRINUSE errors from stale processes. 2. Missing process.exit(1) in client EADDRINUSE handler — the client's error handler logged the error but never exited, leaving the process hanging indefinitely. 3. No SIGINT/SIGTERM graceful shutdown — neither process registered signal handlers, so Ctrl+C left TCP sockets in CLOSE_WAIT state. The next launch would then fail with EADDRINUSE because the OS hadn't released the port. Fixes: - Add checkPort() pre-flight check to both client/bin/client.js and server/src/index.ts that tests port availability before attempting to bind - Add process.exit(1) to the client's EADDRINUSE error handler - Add SIGINT/SIGTERM handlers with server.close() for graceful shutdown in both client and server, with a 3-second force-exit timeout - Provide actionable error messages with lsof/kill instructions Resolves modelcontextprotocol#1090 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR addresses port-binding flakiness in the MCP Inspector by adding a pre-flight port availability check and introducing SIGINT/SIGTERM shutdown handling so ports are released promptly and EADDRINUSE failures are clearer and non-hanging.
Changes:
- Added a
net-basedcheckPort()pre-flight check beforelisten()in both the client and proxy server. - Added
SIGINT/SIGTERMhandlers to close servers on shutdown (with a force-exit timeout). - Improved
EADDRINUSEhandling in the client by ensuring the process exits after logging.
Reviewed changes
Copilot reviewed 1 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| server/src/index.ts | Adds port pre-check before app.listen() and installs graceful shutdown handlers. |
| client/bin/client.js | Adds port pre-check before server.listen(), ensures exit on bind error, and installs graceful shutdown handlers. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| function checkPort(targetHost: string, targetPort: number): Promise<boolean> { | ||
| return new Promise((resolve) => { | ||
| const tester = net.createServer(); | ||
| tester.once("error", () => { | ||
| resolve(false); | ||
| }); | ||
| tester.once("listening", () => { | ||
| tester.close(() => resolve(true)); | ||
| }); | ||
| tester.listen(targetPort, targetHost); | ||
| }); |
There was a problem hiding this comment.
checkPort() currently resolves false for any listen error, which can misreport other failure modes (e.g., EACCES for privileged ports, invalid host) as "PORT IS IN USE". Capture the error and only treat EADDRINUSE as "in use"; for other err.code values either rethrow or surface a more accurate message. Also consider calling tester.close() in the error path to avoid leaving a handle around in edge cases.
| server.on("error", (err) => { | ||
| if (err.message.includes(`EADDRINUSE`)) { | ||
| console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `); | ||
| } else { | ||
| console.error(err.message); | ||
| } | ||
| process.exit(1); |
There was a problem hiding this comment.
The EADDRINUSE detection is using err.message.includes('EADDRINUSE'). Node provides err.code === 'EADDRINUSE', which is more reliable than parsing the message string (which can vary). Consider switching to err.code checks here (and optionally include the original error for non-EADDRINUSE cases).
| function shutdown() { | ||
| server.close(() => { | ||
| process.exit(0); | ||
| }); | ||
| setTimeout(() => process.exit(0), 3000); | ||
| } | ||
|
|
||
| process.on("SIGINT", shutdown); | ||
| process.on("SIGTERM", shutdown); |
There was a problem hiding this comment.
shutdown() calls server.close() unconditionally. In Node, calling close() when the server isn't listening (or calling it twice if multiple signals fire) can throw ERR_SERVER_NOT_RUNNING. Add a guard (e.g., let shuttingDown = false + if (!server.listening) process.exit(0)) and consider clearing the force-exit timeout when close completes.
| const portFree = await checkPort(HOST, PORT); | ||
| if (!portFree) { | ||
| console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `); | ||
| console.error( | ||
| `💡 To fix: run "lsof -ti:${PORT} | xargs kill -9" to free the port, or set SERVER_PORT to use a different port.`, | ||
| ); |
There was a problem hiding this comment.
The remediation text suggests running lsof ... | xargs kill -9, which is Unix-specific and conflicts with the stated goal of Windows compatibility. Consider making the message platform-aware (process.platform) or providing a more platform-neutral suggestion (e.g., "close the process using the port" + link/docs), with Windows equivalents where applicable.
| const portFree = await checkPort(HOST, PORT); | |
| if (!portFree) { | |
| console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `); | |
| console.error( | |
| `💡 To fix: run "lsof -ti:${PORT} | xargs kill -9" to free the port, or set SERVER_PORT to use a different port.`, | |
| ); | |
| function getPortInUseRemediation(targetPort: number): string { | |
| if (process.platform === "win32") { | |
| return `💡 To fix: close the process using port ${targetPort} (for example, run "netstat -ano | findstr :${targetPort}" to find the PID, then "taskkill /PID <PID> /F"), or set SERVER_PORT to use a different port.`; | |
| } | |
| return `💡 To fix: close the process using port ${targetPort} (for example, run "lsof -ti:${targetPort} | xargs kill -9"), or set SERVER_PORT to use a different port.`; | |
| } | |
| const portFree = await checkPort(HOST, PORT); | |
| if (!portFree) { | |
| console.error(`❌ Proxy Server PORT IS IN USE at port ${PORT} ❌ `); | |
| console.error(getPortInUseRemediation(PORT)); |
Summary
Fixes a race condition where the MCP Inspector reports "PORT IS IN USE" even when ports are confirmed free, and leaves ports in CLOSE_WAIT state after Ctrl+C — causing the error to persist across restarts.
Three bugs identified and fixed:
No pre-flight port availability check — Both the client (
client/bin/client.js) and proxy server (server/src/index.ts) attempted to bind directly viaserver.listen()without first verifying the port was free. When a stale process held the port, users received a confusingEADDRINUSEerror with no actionable guidance.Missing
process.exit(1)in client EADDRINUSE handler — The client's error handler logged the error but never calledprocess.exit(1), leaving the process hanging indefinitely. Users had to manually kill the process.No SIGINT/SIGTERM graceful shutdown — Neither the client nor server registered signal handlers, so
Ctrl+Cleft TCP sockets inCLOSE_WAITstate. The OS wouldn't release the port, causing the next launch to fail withEADDRINUSE— even thoughlsofshowed no process on the port.Changes
client/bin/client.jsimport net from "net"checkPort()function that attempts a test bind before the realserver.listen()portandhostdeclarations above the check so they're available for the pre-flight testprocess.exit(1)to the existing EADDRINUSE error handlerserver.close()for graceful shutdown (3s force-exit timeout)lsof/killinstructions in error messagesserver/src/index.tsimport net from "net"with other imports at the top of the filecheckPort()function (Promise<boolean>) beforeapp.listen()server.close()for graceful shutdown (3s force-exit timeout)How to test
npx @modelcontextprotocol/inspectorlsof/killfix instructions and exit cleanlyTest plan
CLIENT_PORT/SERVER_PORTenv var overrides still workResolves #1090
🤖 Generated with Claude Code