localhost-cockpit 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/bin/localhost.js +127 -0
- package/package.json +44 -0
- package/src/actions.js +22 -0
- package/src/discovery.js +153 -0
- package/src/format.js +46 -0
- package/src/mcp.js +170 -0
- package/src/system.js +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 localhost contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# localhost
|
|
2
|
+
|
|
3
|
+
`localhost` is a dependency-free terminal cockpit for the development servers
|
|
4
|
+
running on your computer. It is designed for both humans and AI coding agents.
|
|
5
|
+
|
|
6
|
+
```text
|
|
7
|
+
NAME URL STACK PID BRANCH
|
|
8
|
+
storefront http://localhost:3000 Next.js 48102 checkout-redesign
|
|
9
|
+
api http://localhost:8000 FastAPI 48177 main
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Try it
|
|
13
|
+
|
|
14
|
+
Requires Node.js 20+ and `lsof` (included with macOS and commonly available on
|
|
15
|
+
Linux).
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npm link
|
|
19
|
+
localhost
|
|
20
|
+
localhost inspect 3000
|
|
21
|
+
localhost watch
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
No global installation is needed while developing:
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
node ./bin/localhost.js list
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
localhost list [--json] [--all]
|
|
34
|
+
localhost inspect <pid|port> [--json]
|
|
35
|
+
localhost open <pid|port>
|
|
36
|
+
localhost stop <pid|port> [--force]
|
|
37
|
+
localhost watch [seconds]
|
|
38
|
+
localhost mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The JSON response is versionable, deterministic output for scripts and agents:
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
localhost list --json | jq '.servers[] | {name, port, framework}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
By default, obvious operating-system listeners are hidden and cannot be targeted
|
|
48
|
+
by the inspect or stop commands. Use `localhost list --all` only when diagnosing
|
|
49
|
+
discovery itself.
|
|
50
|
+
|
|
51
|
+
## MCP integration
|
|
52
|
+
|
|
53
|
+
Add the server to an MCP-capable coding agent using a stdio configuration:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"localhost": {
|
|
59
|
+
"command": "node",
|
|
60
|
+
"args": ["/absolute/path/to/localhost/bin/localhost.js", "mcp"]
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Tools exposed:
|
|
67
|
+
|
|
68
|
+
- `list_servers` — discover listening servers and their project context
|
|
69
|
+
- `inspect_server` — inspect a server by port or PID
|
|
70
|
+
- `stop_server` — terminate a verified listener; requires `confirm: true`
|
|
71
|
+
|
|
72
|
+
`stop_server` re-runs discovery immediately before signaling the process. This
|
|
73
|
+
prevents an agent from acting on stale process information.
|
|
74
|
+
|
|
75
|
+
The server supports both the modern MCP `2026-07-28` `server/discover` lifecycle
|
|
76
|
+
and the legacy `initialize` lifecycle. Tool results include both human-readable
|
|
77
|
+
text and schema-declared `structuredContent`.
|
|
78
|
+
|
|
79
|
+
## Current scope
|
|
80
|
+
|
|
81
|
+
This first release supports macOS and Linux systems with `lsof`. Stable local
|
|
82
|
+
domains, aggregated logs, Docker metadata, and a full-screen interactive TUI are
|
|
83
|
+
planned next.
|
|
84
|
+
|
|
85
|
+
## Development
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npm test
|
|
89
|
+
npm run check
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## License
|
|
93
|
+
|
|
94
|
+
MIT
|
package/bin/localhost.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { discoverServers, findServer } from "../src/discovery.js";
|
|
4
|
+
import { formatDetails, formatTable } from "../src/format.js";
|
|
5
|
+
import { runMcpServer } from "../src/mcp.js";
|
|
6
|
+
import { openServer, stopServer } from "../src/actions.js";
|
|
7
|
+
|
|
8
|
+
const VERSION = "0.1.0";
|
|
9
|
+
|
|
10
|
+
function parseArgs(argv) {
|
|
11
|
+
const positional = [];
|
|
12
|
+
const flags = new Set();
|
|
13
|
+
for (const arg of argv) {
|
|
14
|
+
if (arg.startsWith("--")) flags.add(arg);
|
|
15
|
+
else positional.push(arg);
|
|
16
|
+
}
|
|
17
|
+
return { positional, flags };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function printJson(value) {
|
|
21
|
+
process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function help() {
|
|
25
|
+
return `localhost ${VERSION} — your local development cockpit
|
|
26
|
+
|
|
27
|
+
Usage:
|
|
28
|
+
localhost List listening development servers
|
|
29
|
+
localhost list [--json] [--all] List servers as a table or stable JSON
|
|
30
|
+
localhost inspect <pid|port> Show one server in detail
|
|
31
|
+
localhost open <pid|port> Open a server in the default browser
|
|
32
|
+
localhost stop <pid|port> Gracefully stop a server
|
|
33
|
+
localhost stop <pid|port> --force Send SIGKILL instead of SIGTERM
|
|
34
|
+
localhost watch [seconds] Refresh the terminal dashboard
|
|
35
|
+
localhost mcp Run the MCP server over stdio
|
|
36
|
+
|
|
37
|
+
Agent usage:
|
|
38
|
+
localhost list --json
|
|
39
|
+
localhost inspect 3000 --json
|
|
40
|
+
localhost mcp
|
|
41
|
+
|
|
42
|
+
Targets can be a PID, a port, or :port. Destructive actions only operate on
|
|
43
|
+
processes that are still listening when the action is performed.`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function list(json = false, includeSystem = false) {
|
|
47
|
+
const servers = await discoverServers({ includeSystem });
|
|
48
|
+
if (json) printJson({ servers, count: servers.length });
|
|
49
|
+
else process.stdout.write(`${formatTable(servers)}\n`);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function inspect(target, json = false) {
|
|
53
|
+
const servers = await discoverServers();
|
|
54
|
+
const server = findServer(servers, target);
|
|
55
|
+
if (json) printJson(server);
|
|
56
|
+
else process.stdout.write(`${formatDetails(server)}\n`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function watch(secondsArg) {
|
|
60
|
+
const seconds = Number(secondsArg ?? 2);
|
|
61
|
+
if (!Number.isFinite(seconds) || seconds < 0.25) {
|
|
62
|
+
throw new Error("Refresh interval must be at least 0.25 seconds");
|
|
63
|
+
}
|
|
64
|
+
const render = async () => {
|
|
65
|
+
const servers = await discoverServers();
|
|
66
|
+
process.stdout.write("\u001b[2J\u001b[H");
|
|
67
|
+
process.stdout.write(`localhost — ${new Date().toLocaleTimeString()} — Ctrl+C to exit\n\n`);
|
|
68
|
+
process.stdout.write(`${formatTable(servers)}\n`);
|
|
69
|
+
};
|
|
70
|
+
await render();
|
|
71
|
+
setInterval(() => void render(), seconds * 1000);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function main() {
|
|
75
|
+
const { positional, flags } = parseArgs(process.argv.slice(2));
|
|
76
|
+
const [command = "list", target] = positional;
|
|
77
|
+
const json = flags.has("--json");
|
|
78
|
+
|
|
79
|
+
if (flags.has("--help") || command === "help") {
|
|
80
|
+
process.stdout.write(`${help()}\n`);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (flags.has("--version") || command === "version") {
|
|
84
|
+
process.stdout.write(`${VERSION}\n`);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
switch (command) {
|
|
89
|
+
case "list":
|
|
90
|
+
await list(json, flags.has("--all"));
|
|
91
|
+
break;
|
|
92
|
+
case "inspect": {
|
|
93
|
+
if (!target) throw new Error("inspect requires a PID or port");
|
|
94
|
+
await inspect(target, json);
|
|
95
|
+
break;
|
|
96
|
+
}
|
|
97
|
+
case "open": {
|
|
98
|
+
if (!target) throw new Error("open requires a PID or port");
|
|
99
|
+
const result = await openServer(target);
|
|
100
|
+
if (json) printJson(result);
|
|
101
|
+
else process.stdout.write(`Opened ${result.url}\n`);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
case "stop": {
|
|
105
|
+
if (!target) throw new Error("stop requires a PID or port");
|
|
106
|
+
const result = await stopServer(target, { force: flags.has("--force") });
|
|
107
|
+
if (json) printJson(result);
|
|
108
|
+
else process.stdout.write(`Sent ${result.signal} to ${result.name} (PID ${result.pid})\n`);
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
case "watch":
|
|
112
|
+
await watch(target);
|
|
113
|
+
break;
|
|
114
|
+
case "mcp":
|
|
115
|
+
await runMcpServer();
|
|
116
|
+
break;
|
|
117
|
+
default:
|
|
118
|
+
throw new Error(`Unknown command: ${command}\n\n${help()}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
main().catch((error) => {
|
|
123
|
+
const jsonRequested = process.argv.includes("--json");
|
|
124
|
+
if (jsonRequested) printJson({ error: error.message, code: error.code ?? "LOCALHOST_ERROR" });
|
|
125
|
+
else process.stderr.write(`localhost: ${error.message}\n`);
|
|
126
|
+
process.exitCode = 1;
|
|
127
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "localhost-cockpit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "See and control every development server running on your machine.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"localhost",
|
|
7
|
+
"developer-tools",
|
|
8
|
+
"cli",
|
|
9
|
+
"mcp",
|
|
10
|
+
"dev-server"
|
|
11
|
+
],
|
|
12
|
+
"author": "Danilo Lekovic",
|
|
13
|
+
"homepage": "https://github.com/danilolekovic/localhost#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/danilolekovic/localhost/issues"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/danilolekovic/localhost.git"
|
|
20
|
+
},
|
|
21
|
+
"type": "module",
|
|
22
|
+
"bin": {
|
|
23
|
+
"localhost": "bin/localhost.js"
|
|
24
|
+
},
|
|
25
|
+
"files": [
|
|
26
|
+
"bin/",
|
|
27
|
+
"src/",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"start": "node ./bin/localhost.js",
|
|
33
|
+
"test": "node --test",
|
|
34
|
+
"check": "node --check ./bin/localhost.js && node --check ./src/discovery.js && node --check ./src/mcp.js",
|
|
35
|
+
"prepublishOnly": "npm test && npm run check"
|
|
36
|
+
},
|
|
37
|
+
"engines": {
|
|
38
|
+
"node": ">=20"
|
|
39
|
+
},
|
|
40
|
+
"license": "MIT",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/actions.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { discoverServers, findServer } from "./discovery.js";
|
|
3
|
+
|
|
4
|
+
export async function resolveLiveServer(target) {
|
|
5
|
+
return findServer(await discoverServers(), target);
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function stopServer(target, { force = false } = {}) {
|
|
9
|
+
const server = await resolveLiveServer(target);
|
|
10
|
+
const signal = force ? "SIGKILL" : "SIGTERM";
|
|
11
|
+
process.kill(server.pid, signal);
|
|
12
|
+
return { ok: true, action: "stop", signal, pid: server.pid, port: server.port, name: server.name };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function openServer(target) {
|
|
16
|
+
const server = await resolveLiveServer(target);
|
|
17
|
+
const command = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
|
|
18
|
+
const args = process.platform === "win32" ? ["/c", "start", "", server.url] : [server.url];
|
|
19
|
+
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
20
|
+
child.unref();
|
|
21
|
+
return { ok: true, action: "open", pid: server.pid, port: server.port, url: server.url };
|
|
22
|
+
}
|
package/src/discovery.js
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { run, tryRun } from "./system.js";
|
|
4
|
+
|
|
5
|
+
export function parseLsofFieldOutput(output) {
|
|
6
|
+
const listeners = [];
|
|
7
|
+
let process = null;
|
|
8
|
+
let file = null;
|
|
9
|
+
|
|
10
|
+
const flushFile = () => {
|
|
11
|
+
if (!process || !file?.name) return;
|
|
12
|
+
const match = file.name.match(/(?:\]|:)(\d+)(?:\s+\(LISTEN\))?$/);
|
|
13
|
+
if (!match) return;
|
|
14
|
+
listeners.push({
|
|
15
|
+
pid: Number(process.pid),
|
|
16
|
+
commandName: process.commandName,
|
|
17
|
+
address: file.name.replace(/\s+\(LISTEN\)$/, ""),
|
|
18
|
+
port: Number(match[1]),
|
|
19
|
+
});
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
for (const line of output.split("\n")) {
|
|
23
|
+
if (!line) continue;
|
|
24
|
+
const field = line[0];
|
|
25
|
+
const value = line.slice(1);
|
|
26
|
+
if (field === "p") {
|
|
27
|
+
flushFile();
|
|
28
|
+
process = { pid: value, commandName: "unknown" };
|
|
29
|
+
file = null;
|
|
30
|
+
} else if (field === "c" && process) {
|
|
31
|
+
process.commandName = value;
|
|
32
|
+
} else if (field === "f") {
|
|
33
|
+
flushFile();
|
|
34
|
+
file = { fd: value, name: "" };
|
|
35
|
+
} else if (field === "n" && file) {
|
|
36
|
+
file.name = value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
flushFile();
|
|
40
|
+
return listeners;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function frameworkFrom(pkg, command) {
|
|
44
|
+
const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
|
|
45
|
+
const candidates = [
|
|
46
|
+
["next", "Next.js"], ["vite", "Vite"], ["astro", "Astro"],
|
|
47
|
+
["nuxt", "Nuxt"], ["@remix-run/dev", "Remix"], ["gatsby", "Gatsby"],
|
|
48
|
+
["react-scripts", "Create React App"], ["fastify", "Fastify"],
|
|
49
|
+
["express", "Express"], ["@nestjs/core", "NestJS"],
|
|
50
|
+
];
|
|
51
|
+
for (const [dependency, label] of candidates) {
|
|
52
|
+
if (deps?.[dependency]) return label;
|
|
53
|
+
}
|
|
54
|
+
if (/rails/i.test(command)) return "Rails";
|
|
55
|
+
if (/django|manage\.py/i.test(command)) return "Django";
|
|
56
|
+
if (/uvicorn|fastapi/i.test(command)) return "FastAPI";
|
|
57
|
+
if (/python/i.test(command)) return "Python";
|
|
58
|
+
if (/node|bun|deno/i.test(command)) return "Node.js";
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isLikelyDevelopmentServer({ cwd, command, framework }) {
|
|
63
|
+
if (framework) return true;
|
|
64
|
+
if (/\b(node|npm|npx|pnpm|yarn|bun|deno|python|ruby|rails|php|java|cargo|go run|dotnet)\b/i.test(command)) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (!cwd || cwd === "/") return false;
|
|
68
|
+
return !/^\/(System|Library|usr|bin|sbin|private\/var)(?:\/|$)/.test(cwd);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
async function packageInfo(cwd) {
|
|
72
|
+
if (!cwd) return { pkg: null, packageName: null };
|
|
73
|
+
try {
|
|
74
|
+
const pkg = JSON.parse(await readFile(path.join(cwd, "package.json"), "utf8"));
|
|
75
|
+
return { pkg, packageName: pkg.name ?? null };
|
|
76
|
+
} catch {
|
|
77
|
+
return { pkg: null, packageName: null };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function enrich(listener) {
|
|
82
|
+
const pid = String(listener.pid);
|
|
83
|
+
const cwdRaw = await tryRun("lsof", ["-a", "-p", pid, "-d", "cwd", "-Fn"]);
|
|
84
|
+
const cwd = cwdRaw.split("\n").find((line) => line.startsWith("n"))?.slice(1) ?? null;
|
|
85
|
+
const command = await tryRun("ps", ["-p", pid, "-o", "command="], listener.commandName);
|
|
86
|
+
const { pkg, packageName } = await packageInfo(cwd);
|
|
87
|
+
const gitBranch = cwd
|
|
88
|
+
? await tryRun("git", ["-C", cwd, "branch", "--show-current"], "")
|
|
89
|
+
: "";
|
|
90
|
+
const host = /(?:127\.0\.0\.1|\[::1\]|localhost)/.test(listener.address)
|
|
91
|
+
? "localhost"
|
|
92
|
+
: "localhost";
|
|
93
|
+
const protocol = [443, 8443].includes(listener.port) ? "https" : "http";
|
|
94
|
+
const framework = frameworkFrom(pkg, command);
|
|
95
|
+
const name = packageName || (cwd && cwd !== "/" ? path.basename(cwd) : null) || listener.commandName;
|
|
96
|
+
const server = {
|
|
97
|
+
id: `${listener.pid}:${listener.port}`,
|
|
98
|
+
pid: listener.pid,
|
|
99
|
+
port: listener.port,
|
|
100
|
+
address: listener.address,
|
|
101
|
+
url: `${protocol}://${host}:${listener.port}`,
|
|
102
|
+
name,
|
|
103
|
+
process: listener.commandName,
|
|
104
|
+
command,
|
|
105
|
+
cwd,
|
|
106
|
+
framework,
|
|
107
|
+
gitBranch: gitBranch || null,
|
|
108
|
+
};
|
|
109
|
+
return { ...server, development: isLikelyDevelopmentServer(server) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function discoverServers({ includeSystem = false } = {}) {
|
|
113
|
+
let output;
|
|
114
|
+
try {
|
|
115
|
+
output = await run("lsof", ["-nP", "-iTCP", "-sTCP:LISTEN", "-Fpcfn"]);
|
|
116
|
+
} catch (error) {
|
|
117
|
+
if (error.code === "ENOENT") {
|
|
118
|
+
const wrapped = new Error("lsof is required for process discovery");
|
|
119
|
+
wrapped.code = "MISSING_LSOF";
|
|
120
|
+
throw wrapped;
|
|
121
|
+
}
|
|
122
|
+
return [];
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const parsed = parseLsofFieldOutput(output);
|
|
126
|
+
const unique = [...new Map(parsed.map((item) => [`${item.pid}:${item.port}`, item])).values()];
|
|
127
|
+
const servers = await Promise.all(unique.map(enrich));
|
|
128
|
+
return servers
|
|
129
|
+
.filter((server) => includeSystem || server.development)
|
|
130
|
+
.sort((a, b) => a.port - b.port || a.pid - b.pid);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function findServer(servers, target) {
|
|
134
|
+
const normalized = String(target).replace(/^:/, "");
|
|
135
|
+
const number = Number(normalized);
|
|
136
|
+
if (!Number.isInteger(number) || number <= 0) {
|
|
137
|
+
const error = new Error(`Invalid target: ${target}. Use a PID, port, or :port.`);
|
|
138
|
+
error.code = "INVALID_TARGET";
|
|
139
|
+
throw error;
|
|
140
|
+
}
|
|
141
|
+
const byPort = servers.filter((server) => server.port === number);
|
|
142
|
+
const byPid = servers.filter((server) => server.pid === number);
|
|
143
|
+
const matches = [...new Map([...byPort, ...byPid].map((server) => [server.id ?? `${server.pid}:${server.port}`, server])).values()];
|
|
144
|
+
if (matches.length === 1) return matches[0];
|
|
145
|
+
if (matches.length > 1) {
|
|
146
|
+
const error = new Error(`Target ${target} is ambiguous; use the server's port instead.`);
|
|
147
|
+
error.code = "AMBIGUOUS_TARGET";
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
const error = new Error(`No listening server found for ${target}`);
|
|
151
|
+
error.code = "SERVER_NOT_FOUND";
|
|
152
|
+
throw error;
|
|
153
|
+
}
|
package/src/format.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const color = {
|
|
2
|
+
dim: (value) => `\u001b[2m${value}\u001b[22m`,
|
|
3
|
+
cyan: (value) => `\u001b[36m${value}\u001b[39m`,
|
|
4
|
+
green: (value) => `\u001b[32m${value}\u001b[39m`,
|
|
5
|
+
bold: (value) => `\u001b[1m${value}\u001b[22m`,
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function plainLength(value) {
|
|
9
|
+
return String(value).replace(/\u001b\[[0-9;]*m/g, "").length;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function pad(value, width) {
|
|
13
|
+
return `${value}${" ".repeat(Math.max(0, width - plainLength(value)))}`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function formatTable(servers) {
|
|
17
|
+
if (servers.length === 0) return "No listening TCP servers found.";
|
|
18
|
+
const rows = servers.map((server) => [
|
|
19
|
+
server.name,
|
|
20
|
+
server.url,
|
|
21
|
+
server.framework ?? server.process,
|
|
22
|
+
String(server.pid),
|
|
23
|
+
server.gitBranch ?? "—",
|
|
24
|
+
]);
|
|
25
|
+
const headers = ["NAME", "URL", "STACK", "PID", "BRANCH"];
|
|
26
|
+
const widths = headers.map((header, index) =>
|
|
27
|
+
Math.max(header.length, ...rows.map((row) => plainLength(row[index])))
|
|
28
|
+
);
|
|
29
|
+
const header = headers.map((cell, index) => pad(color.dim(cell), widths[index])).join(" ");
|
|
30
|
+
const body = rows.map((row) => row.map((cell, index) => {
|
|
31
|
+
const styled = index === 0 ? color.bold(cell) : index === 1 ? color.cyan(cell) : index === 3 ? color.green(cell) : cell;
|
|
32
|
+
return pad(styled, widths[index]);
|
|
33
|
+
}).join(" "));
|
|
34
|
+
return [header, ...body].join("\n");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function formatDetails(server) {
|
|
38
|
+
const entries = [
|
|
39
|
+
["Name", server.name], ["URL", server.url], ["PID", server.pid],
|
|
40
|
+
["Process", server.process], ["Framework", server.framework],
|
|
41
|
+
["Branch", server.gitBranch], ["Directory", server.cwd],
|
|
42
|
+
["Command", server.command], ["Address", server.address],
|
|
43
|
+
].filter(([, value]) => value !== null && value !== "");
|
|
44
|
+
const width = Math.max(...entries.map(([key]) => key.length));
|
|
45
|
+
return entries.map(([key, value]) => `${color.dim(pad(key, width))} ${value}`).join("\n");
|
|
46
|
+
}
|
package/src/mcp.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { discoverServers, findServer } from "./discovery.js";
|
|
2
|
+
import { stopServer } from "./actions.js";
|
|
3
|
+
|
|
4
|
+
const tools = [
|
|
5
|
+
{
|
|
6
|
+
name: "list_servers",
|
|
7
|
+
description: "List TCP servers currently listening on the developer's machine, including ports, projects, frameworks, and git branches.",
|
|
8
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
9
|
+
outputSchema: { type: "object" },
|
|
10
|
+
},
|
|
11
|
+
{
|
|
12
|
+
name: "inspect_server",
|
|
13
|
+
description: "Inspect a listening server by TCP port or process ID.",
|
|
14
|
+
inputSchema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: { target: { type: ["string", "number"], description: "A TCP port or process ID" } },
|
|
17
|
+
required: ["target"], additionalProperties: false,
|
|
18
|
+
},
|
|
19
|
+
outputSchema: { type: "object" },
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: "stop_server",
|
|
23
|
+
description: "Stop a verified listening server. Requires confirm=true because this terminates a local process.",
|
|
24
|
+
inputSchema: {
|
|
25
|
+
type: "object",
|
|
26
|
+
properties: {
|
|
27
|
+
target: { type: ["string", "number"], description: "A TCP port or process ID" },
|
|
28
|
+
confirm: { type: "boolean", description: "Must be true to confirm process termination" },
|
|
29
|
+
force: { type: "boolean", default: false, description: "Use SIGKILL instead of SIGTERM" },
|
|
30
|
+
},
|
|
31
|
+
required: ["target", "confirm"], additionalProperties: false,
|
|
32
|
+
},
|
|
33
|
+
outputSchema: { type: "object" },
|
|
34
|
+
},
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const MODERN_VERSION = "2026-07-28";
|
|
38
|
+
const LEGACY_VERSION = "2025-11-25";
|
|
39
|
+
const SERVER_INFO = { name: "localhost", version: "0.1.0" };
|
|
40
|
+
|
|
41
|
+
function response(id, result) {
|
|
42
|
+
return { jsonrpc: "2.0", id, result };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function errorResponse(id, code, message) {
|
|
46
|
+
return { jsonrpc: "2.0", id, error: { code, message } };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function textResult(value, isError = false) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
|
|
52
|
+
...(isError ? {} : { structuredContent: value }),
|
|
53
|
+
isError,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function modernResponse(message, result) {
|
|
58
|
+
const modern = message.method === "server/discover"
|
|
59
|
+
|| message.params?._meta?.["io.modelcontextprotocol/protocolVersion"] === MODERN_VERSION;
|
|
60
|
+
if (!modern) return response(message.id, result);
|
|
61
|
+
return response(message.id, {
|
|
62
|
+
...result,
|
|
63
|
+
_meta: {
|
|
64
|
+
...result?._meta,
|
|
65
|
+
"io.modelcontextprotocol/serverInfo": SERVER_INFO,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function handleMcpMessage(message) {
|
|
71
|
+
const { id, method, params = {} } = message;
|
|
72
|
+
if (method === "server/discover") {
|
|
73
|
+
return modernResponse(message, {
|
|
74
|
+
supportedVersions: [MODERN_VERSION, LEGACY_VERSION],
|
|
75
|
+
capabilities: { tools: {} },
|
|
76
|
+
instructions: "Discover and inspect local development servers. Stop a process only after explicit user confirmation.",
|
|
77
|
+
ttlMs: 60_000,
|
|
78
|
+
cacheScope: "private",
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
if (method === "initialize") {
|
|
82
|
+
const requested = params.protocolVersion;
|
|
83
|
+
const protocolVersion = requested && requested !== MODERN_VERSION ? requested : LEGACY_VERSION;
|
|
84
|
+
return response(id, {
|
|
85
|
+
protocolVersion,
|
|
86
|
+
capabilities: { tools: {} },
|
|
87
|
+
serverInfo: SERVER_INFO,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (method === "notifications/initialized") return null;
|
|
91
|
+
if (method === "ping") return modernResponse(message, {});
|
|
92
|
+
if (method === "tools/list") return modernResponse(message, { tools });
|
|
93
|
+
if (method === "tools/call") {
|
|
94
|
+
const args = params.arguments ?? {};
|
|
95
|
+
try {
|
|
96
|
+
if (params.name === "list_servers") {
|
|
97
|
+
const servers = await discoverServers();
|
|
98
|
+
return modernResponse(message, textResult({ servers, count: servers.length }));
|
|
99
|
+
}
|
|
100
|
+
if (params.name === "inspect_server") {
|
|
101
|
+
const server = findServer(await discoverServers(), args.target);
|
|
102
|
+
return modernResponse(message, textResult(server));
|
|
103
|
+
}
|
|
104
|
+
if (params.name === "stop_server") {
|
|
105
|
+
if (args.confirm !== true) {
|
|
106
|
+
return modernResponse(message, textResult({ error: "Set confirm=true to stop the process." }, true));
|
|
107
|
+
}
|
|
108
|
+
return modernResponse(message, textResult(await stopServer(args.target, { force: args.force === true })));
|
|
109
|
+
}
|
|
110
|
+
return errorResponse(id, -32601, `Unknown tool: ${params.name}`);
|
|
111
|
+
} catch (error) {
|
|
112
|
+
return modernResponse(message, textResult({ error: error.message, code: error.code ?? "LOCALHOST_ERROR" }, true));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return errorResponse(id, -32601, `Method not found: ${method}`);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function writeMessage(message, framing = "newline") {
|
|
119
|
+
const body = JSON.stringify(message);
|
|
120
|
+
if (framing === "content-length") {
|
|
121
|
+
process.stdout.write(`Content-Length: ${Buffer.byteLength(body)}\r\n\r\n${body}`);
|
|
122
|
+
} else {
|
|
123
|
+
process.stdout.write(`${body}\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function runMcpServer() {
|
|
128
|
+
let buffer = Buffer.alloc(0);
|
|
129
|
+
process.stdin.on("data", async (chunk) => {
|
|
130
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
131
|
+
while (true) {
|
|
132
|
+
// Modern MCP stdio uses one JSON-RPC message per line. Content-Length
|
|
133
|
+
// framing is also accepted for compatibility with older clients.
|
|
134
|
+
if (!buffer.toString("utf8", 0, Math.min(buffer.length, 15)).startsWith("Content-Length:")) {
|
|
135
|
+
const newline = buffer.indexOf("\n");
|
|
136
|
+
if (newline === -1) break;
|
|
137
|
+
const body = buffer.subarray(0, newline).toString("utf8").trim();
|
|
138
|
+
buffer = buffer.subarray(newline + 1);
|
|
139
|
+
if (!body) continue;
|
|
140
|
+
try {
|
|
141
|
+
const result = await handleMcpMessage(JSON.parse(body));
|
|
142
|
+
if (result) writeMessage(result, "newline");
|
|
143
|
+
} catch (error) {
|
|
144
|
+
writeMessage(errorResponse(null, -32700, error.message), "newline");
|
|
145
|
+
}
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const separator = buffer.indexOf("\r\n\r\n");
|
|
149
|
+
if (separator === -1) break;
|
|
150
|
+
const header = buffer.subarray(0, separator).toString("utf8");
|
|
151
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
152
|
+
if (!match) {
|
|
153
|
+
buffer = buffer.subarray(separator + 4);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const length = Number(match[1]);
|
|
157
|
+
const bodyStart = separator + 4;
|
|
158
|
+
if (buffer.length < bodyStart + length) break;
|
|
159
|
+
const body = buffer.subarray(bodyStart, bodyStart + length).toString("utf8");
|
|
160
|
+
buffer = buffer.subarray(bodyStart + length);
|
|
161
|
+
try {
|
|
162
|
+
const result = await handleMcpMessage(JSON.parse(body));
|
|
163
|
+
if (result) writeMessage(result, "content-length");
|
|
164
|
+
} catch (error) {
|
|
165
|
+
writeMessage(errorResponse(null, -32700, error.message), "content-length");
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
process.stdin.resume();
|
|
170
|
+
}
|
package/src/system.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { promisify } from "node:util";
|
|
3
|
+
|
|
4
|
+
const execFileAsync = promisify(execFile);
|
|
5
|
+
|
|
6
|
+
export async function run(command, args = [], options = {}) {
|
|
7
|
+
const { stdout } = await execFileAsync(command, args, {
|
|
8
|
+
encoding: "utf8",
|
|
9
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
10
|
+
...options,
|
|
11
|
+
});
|
|
12
|
+
return stdout.trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function tryRun(command, args = [], fallback = "") {
|
|
16
|
+
try {
|
|
17
|
+
return await run(command, args);
|
|
18
|
+
} catch {
|
|
19
|
+
return fallback;
|
|
20
|
+
}
|
|
21
|
+
}
|