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 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
@@ -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
+ }
@@ -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
+ }