@imenam/mcp-ssh-gate 0.0.0-stage → 1.0.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/README.md +128 -2
- package/dist/shared/model.d.ts +76 -0
- package/dist/shared/model.js +7 -0
- package/dist/src/cli/setup-mcp.d.ts +21 -0
- package/dist/src/cli/setup-mcp.js +137 -0
- package/dist/src/core/config.d.ts +14 -0
- package/dist/src/core/config.js +36 -0
- package/dist/src/core/errors.d.ts +4 -0
- package/dist/src/core/errors.js +4 -0
- package/dist/src/core/executor.d.ts +46 -0
- package/dist/src/core/executor.js +328 -0
- package/dist/src/core/files.d.ts +9 -0
- package/dist/src/core/files.js +63 -0
- package/dist/src/core/heartbeat.d.ts +5 -0
- package/dist/src/core/heartbeat.js +26 -0
- package/dist/src/core/request-store.d.ts +26 -0
- package/dist/src/core/request-store.js +115 -0
- package/dist/src/core/server-store.d.ts +42 -0
- package/dist/src/core/server-store.js +177 -0
- package/dist/src/core/ssh-session.d.ts +72 -0
- package/dist/src/core/ssh-session.js +243 -0
- package/dist/src/core/vault.d.ts +8 -0
- package/dist/src/core/vault.js +45 -0
- package/dist/src/gui-worker.d.ts +1 -0
- package/dist/src/gui-worker.js +212 -0
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +149 -0
- package/dist/src/tools/format.d.ts +4 -0
- package/dist/src/tools/format.js +66 -0
- package/dist/src/tools/tools.d.ts +14 -0
- package/dist/src/tools/tools.js +247 -0
- package/dist/src/version.d.ts +1 -0
- package/dist/src/version.js +16 -0
- package/gui/dist/assets/index-B2ykxkbY.css +1 -0
- package/gui/dist/assets/index-D1Fg5r8H.js +10 -0
- package/gui/dist/assets/inter-cyrillic-ext-wght-normal-BOeWTOD4.woff2 +0 -0
- package/gui/dist/assets/inter-cyrillic-wght-normal-DqGufNeO.woff2 +0 -0
- package/gui/dist/assets/inter-greek-ext-wght-normal-DlzME5K_.woff2 +0 -0
- package/gui/dist/assets/inter-greek-wght-normal-CkhJZR-_.woff2 +0 -0
- package/gui/dist/assets/inter-latin-ext-wght-normal-DO1Apj_S.woff2 +0 -0
- package/gui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
- package/gui/dist/assets/inter-vietnamese-wght-normal-CBcvBZtf.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-cyrillic-wght-normal-D73BlboJ.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-greek-wght-normal-Bw9x6K1M.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-ext-wght-normal-DBQx-q_a.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-latin-wght-normal-B9CIFXIH.woff2 +0 -0
- package/gui/dist/assets/jetbrains-mono-vietnamese-wght-normal-Bt-aOZkq.woff2 +0 -0
- package/gui/dist/index.html +13 -0
- package/package.json +58 -4
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { serve } from "@hono/node-server";
|
|
5
|
+
import { serveStatic } from "@hono/node-server/serve-static";
|
|
6
|
+
import { Hono } from "hono";
|
|
7
|
+
import { GUI_LISTEN_HOST, ProxyClient, createLogger, honoGuiGuard, setupLogging } from "@imenam/mcp-gui-interface";
|
|
8
|
+
import { APP_VERSION } from "./version.js";
|
|
9
|
+
import { findProjectRoot, loadConfig } from "./core/config.js";
|
|
10
|
+
import { GateError } from "./core/errors.js";
|
|
11
|
+
import { Executor } from "./core/executor.js";
|
|
12
|
+
import { HEARTBEAT_INTERVAL_MS, writeHeartbeat } from "./core/heartbeat.js";
|
|
13
|
+
import { RequestStore } from "./core/request-store.js";
|
|
14
|
+
import { ServerStore } from "./core/server-store.js";
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const rootDir = findProjectRoot(__dirname);
|
|
17
|
+
const staticRoot = path.resolve(rootDir, "gui/dist");
|
|
18
|
+
const configuredAppPath = process.env.APP_PATH || "/ssh-gate";
|
|
19
|
+
let effectiveAppPath = configuredAppPath;
|
|
20
|
+
const config = loadConfig();
|
|
21
|
+
setupLogging({ processLabel: "mcp-ssh-gate:GUI", logDir: config.logDir });
|
|
22
|
+
const logger = createLogger("mcp-ssh-gate:GUI");
|
|
23
|
+
process.on("uncaughtException", (err) => {
|
|
24
|
+
logger.error(`Uncaught exception: ${err.message}`);
|
|
25
|
+
logger.error(err.stack || "No stack trace");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
});
|
|
28
|
+
process.on("unhandledRejection", (reason) => {
|
|
29
|
+
logger.error(`Unhandled rejection: ${reason}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
});
|
|
32
|
+
const servers = new ServerStore(config.dataDir, config.vaultKey);
|
|
33
|
+
const requests = new RequestStore(config.dataDir);
|
|
34
|
+
const executor = new Executor(servers, requests, (message) => logger.info(message));
|
|
35
|
+
// Le worker s'arrête avec le processus MCP qui l'a lancé : canal IPC fermé, ou PID parent disparu.
|
|
36
|
+
const parentPid = process.ppid;
|
|
37
|
+
const parentCheck = setInterval(() => {
|
|
38
|
+
let parentAlive = process.connected;
|
|
39
|
+
if (parentAlive) {
|
|
40
|
+
try {
|
|
41
|
+
process.kill(parentPid, 0);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
parentAlive = false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (!parentAlive) {
|
|
48
|
+
logger.info("MCP master is gone. Exiting.");
|
|
49
|
+
clearInterval(parentCheck);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
}, 2000);
|
|
53
|
+
function safeSend(message) {
|
|
54
|
+
if (!process.send || !process.connected)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
process.send({ ...message, timestamp: new Date().toISOString() });
|
|
58
|
+
}
|
|
59
|
+
catch (err) {
|
|
60
|
+
logger.error(`[IPC] Failed to send message: ${err.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
process.on("message", (msg) => {
|
|
64
|
+
if (msg?.type === "GET_HEALTH") {
|
|
65
|
+
safeSend({ type: "HEALTH_RESPONSE", correlationId: msg.correlationId, data: { status: "ok", uptime: process.uptime() } });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
process.on("disconnect", () => {
|
|
69
|
+
logger.warn("IPC disconnected from MCP master. Exiting.");
|
|
70
|
+
process.exit(0);
|
|
71
|
+
});
|
|
72
|
+
// --- API -----------------------------------------------------------------------
|
|
73
|
+
const app = new Hono();
|
|
74
|
+
app.use("*", honoGuiGuard());
|
|
75
|
+
async function readBody(c) {
|
|
76
|
+
try {
|
|
77
|
+
return await c.req.json();
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
throw new GateError("Corps de requête JSON invalide.");
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function handle(fn) {
|
|
84
|
+
return async (c) => {
|
|
85
|
+
try {
|
|
86
|
+
return c.json((await fn(c)));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
if (err instanceof GateError)
|
|
90
|
+
return c.json({ error: err.message }, 400);
|
|
91
|
+
logger.error(`${c.req.method} ${c.req.path}: ${err?.stack ?? err}`);
|
|
92
|
+
return c.json({ error: err?.message ?? String(err) }, 500);
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function serverInput(body) {
|
|
97
|
+
return { name: body?.name, host: body?.host, port: Number(body?.port), username: body?.username };
|
|
98
|
+
}
|
|
99
|
+
function commandIndex(c) {
|
|
100
|
+
const index = Number(c.req.param("index"));
|
|
101
|
+
if (!Number.isInteger(index) || index < 0)
|
|
102
|
+
throw new GateError("Index de commande invalide.");
|
|
103
|
+
return index;
|
|
104
|
+
}
|
|
105
|
+
app.get("/api/state", handle(() => ({ version: APP_VERSION, servers: executor.views() })));
|
|
106
|
+
app.get("/api/servers/:id/requests", handle((c) => {
|
|
107
|
+
const server = servers.get(c.req.param("id"));
|
|
108
|
+
const limit = Math.min(Math.max(Number(c.req.query("limit") ?? 50) || 50, 1), 200);
|
|
109
|
+
return { requests: requests.listForServer(server.id, limit) };
|
|
110
|
+
}));
|
|
111
|
+
app.post("/api/servers", handle(async (c) => executor.addServer(serverInput(await readBody(c)))));
|
|
112
|
+
app.put("/api/servers/:id", handle(async (c) => executor.updateServer(c.req.param("id"), serverInput(await readBody(c)))));
|
|
113
|
+
app.delete("/api/servers/:id", handle((c) => {
|
|
114
|
+
executor.deleteServer(c.req.param("id"));
|
|
115
|
+
return { deleted: true };
|
|
116
|
+
}));
|
|
117
|
+
app.post("/api/servers/:id/test", handle((c) => executor.testConnection(c.req.param("id"))));
|
|
118
|
+
app.post("/api/servers/:id/disconnect", handle((c) => {
|
|
119
|
+
const server = servers.get(c.req.param("id"));
|
|
120
|
+
executor.disconnect(server.id);
|
|
121
|
+
return executor.view(servers.get(server.id));
|
|
122
|
+
}));
|
|
123
|
+
app.put("/api/servers/:id/notes", handle(async (c) => {
|
|
124
|
+
const body = await readBody(c);
|
|
125
|
+
const server = servers.get(c.req.param("id"));
|
|
126
|
+
const expected = body?.expectedUpdatedAt === undefined ? null : body.expectedUpdatedAt;
|
|
127
|
+
return executor.view(servers.setNotes(server.id, body?.notes, "user", expected));
|
|
128
|
+
}));
|
|
129
|
+
app.post("/api/servers/:id/manual", handle(async (c) => executor.runManual(c.req.param("id"), (await readBody(c))?.command)));
|
|
130
|
+
app.put("/api/requests/:id/commands/:index", handle(async (c) => executor.editCommand(c.req.param("id"), commandIndex(c), (await readBody(c))?.command)));
|
|
131
|
+
app.delete("/api/requests/:id/commands/:index", handle((c) => executor.removeCommand(c.req.param("id"), commandIndex(c))));
|
|
132
|
+
app.post("/api/requests/:id/approve", handle(async (c) => executor.approve(c.req.param("id"), (await readBody(c))?.note)));
|
|
133
|
+
app.post("/api/requests/:id/refuse", handle(async (c) => executor.refuse(c.req.param("id"), (await readBody(c))?.note)));
|
|
134
|
+
app.post("/api/requests/:id/interrupt", handle(async (c) => {
|
|
135
|
+
await executor.interrupt(c.req.param("id"));
|
|
136
|
+
return { interrupted: true };
|
|
137
|
+
}));
|
|
138
|
+
app.use("/assets/*", serveStatic({ root: path.relative(process.cwd(), staticRoot) }));
|
|
139
|
+
function baseHref(appPath) {
|
|
140
|
+
const normalized = appPath.startsWith("/") ? appPath : `/${appPath}`;
|
|
141
|
+
return normalized.endsWith("/") ? normalized : `${normalized}/`;
|
|
142
|
+
}
|
|
143
|
+
let standalone = false;
|
|
144
|
+
app.get("/*", (c, next) => {
|
|
145
|
+
if (c.req.path.startsWith("/api") || c.req.path.startsWith("/assets"))
|
|
146
|
+
return next();
|
|
147
|
+
const indexPath = path.join(staticRoot, "index.html");
|
|
148
|
+
if (!fs.existsSync(indexPath))
|
|
149
|
+
return c.text(`GUI build not found at ${staticRoot}`, 404);
|
|
150
|
+
const href = standalone || c.req.query("__direct") === "1" ? "/" : baseHref(effectiveAppPath);
|
|
151
|
+
return c.html(fs.readFileSync(indexPath, "utf8").replace("<head>", `<head>\n <base href="${href}" />`));
|
|
152
|
+
});
|
|
153
|
+
// --- Démarrage -----------------------------------------------------------------
|
|
154
|
+
function listen(port, publicUrl) {
|
|
155
|
+
const instance = serve({ fetch: app.fetch, port, hostname: GUI_LISTEN_HOST }, (info) => {
|
|
156
|
+
logger.info(`GUI listening on port ${info.port}`);
|
|
157
|
+
executor.recoverAbandoned();
|
|
158
|
+
writeHeartbeat(config.dataDir, publicUrl);
|
|
159
|
+
setInterval(() => writeHeartbeat(config.dataDir, publicUrl), HEARTBEAT_INTERVAL_MS);
|
|
160
|
+
safeSend({ type: "READY", data: { port: info.port, url: publicUrl } });
|
|
161
|
+
});
|
|
162
|
+
instance.on("error", (err) => {
|
|
163
|
+
if (err.code === "EADDRINUSE") {
|
|
164
|
+
logger.info(`Port ${port} already in use — another GUI worker is running.`);
|
|
165
|
+
safeSend({ type: "ALREADY_RUNNING", data: { url: publicUrl } });
|
|
166
|
+
process.exit(0);
|
|
167
|
+
}
|
|
168
|
+
logger.error(`Server error: ${err.message}`);
|
|
169
|
+
process.exit(1);
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
async function start() {
|
|
173
|
+
const proxyUrl = process.env.PROXY_URL?.trim();
|
|
174
|
+
if (!proxyUrl) {
|
|
175
|
+
const port = Number(process.env.APP_PORT?.trim());
|
|
176
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
177
|
+
logger.error(`APP_PORT is invalid (${process.env.APP_PORT}): expected an integer between 1 and 65535.`);
|
|
178
|
+
process.exit(1);
|
|
179
|
+
}
|
|
180
|
+
standalone = true;
|
|
181
|
+
listen(port, `http://localhost:${port}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const proxyClient = new ProxyClient(proxyUrl);
|
|
185
|
+
const result = await proxyClient.register({
|
|
186
|
+
path: configuredAppPath,
|
|
187
|
+
name: process.env.APP_NAME || "SSH Gate",
|
|
188
|
+
group: process.env.APP_GROUP,
|
|
189
|
+
});
|
|
190
|
+
if (result.error === "HTTP 409") {
|
|
191
|
+
logger.info("Another GUI worker is already registered with the proxy.");
|
|
192
|
+
safeSend({ type: "ALREADY_RUNNING", data: { url: `${proxyUrl}${configuredAppPath}` } });
|
|
193
|
+
process.exit(0);
|
|
194
|
+
}
|
|
195
|
+
if (!result.success) {
|
|
196
|
+
logger.error(`Proxy registration failed: ${result.error}`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
effectiveAppPath = result.path ?? configuredAppPath;
|
|
200
|
+
const publicUrl = result.url || `${proxyUrl}${effectiveAppPath}`;
|
|
201
|
+
const cleanup = async () => {
|
|
202
|
+
await proxyClient.unregister();
|
|
203
|
+
process.exit(0);
|
|
204
|
+
};
|
|
205
|
+
process.on("SIGTERM", cleanup);
|
|
206
|
+
process.on("SIGINT", cleanup);
|
|
207
|
+
listen(result.port, publicUrl);
|
|
208
|
+
}
|
|
209
|
+
start().catch((err) => {
|
|
210
|
+
logger.error(`Failed to start the GUI worker: ${err.message}`);
|
|
211
|
+
process.exit(1);
|
|
212
|
+
});
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import dotenv from "dotenv";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
9
|
+
import { GuiLauncher, createLogger, setupLogging } from "@imenam/mcp-gui-interface";
|
|
10
|
+
import { APP_VERSION } from "./version.js";
|
|
11
|
+
import { assertGuiConfigured, findProjectRoot, loadConfig } from "./core/config.js";
|
|
12
|
+
import { liveGuiUrl } from "./core/heartbeat.js";
|
|
13
|
+
import { RequestStore } from "./core/request-store.js";
|
|
14
|
+
import { ServerStore } from "./core/server-store.js";
|
|
15
|
+
import { generateVaultKey } from "./core/vault.js";
|
|
16
|
+
import { TOOL_DEFINITIONS, callTool } from "./tools/tools.js";
|
|
17
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
if (argv.includes("--generate-vault-key")) {
|
|
20
|
+
console.log(generateVaultKey());
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
if (argv.includes("--claude-setup-mcp")) {
|
|
24
|
+
const { runSetupMcp, parseGuiExposure } = await import("./cli/setup-mcp.js");
|
|
25
|
+
const parsed = parseGuiExposure(argv);
|
|
26
|
+
if ("error" in parsed) {
|
|
27
|
+
console.error(`Erreur : ${parsed.error}`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
process.exit(runSetupMcp({ cwd: process.cwd(), force: argv.includes("--force"), exposure: parsed.exposure }));
|
|
31
|
+
}
|
|
32
|
+
const rootDir = findProjectRoot(__dirname);
|
|
33
|
+
const packageEnvPath = path.join(rootDir, ".env");
|
|
34
|
+
if (fs.existsSync(packageEnvPath))
|
|
35
|
+
dotenv.config({ path: packageEnvPath, override: true, quiet: true });
|
|
36
|
+
let config;
|
|
37
|
+
try {
|
|
38
|
+
config = loadConfig();
|
|
39
|
+
assertGuiConfigured();
|
|
40
|
+
}
|
|
41
|
+
catch (err) {
|
|
42
|
+
process.stderr.write(`[mcp-ssh-gate] ${err.message}\n`);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
setupLogging({ processLabel: "mcp-ssh-gate", logDir: config.logDir });
|
|
46
|
+
const logger = createLogger("mcp-ssh-gate");
|
|
47
|
+
const servers = new ServerStore(config.dataDir, config.vaultKey);
|
|
48
|
+
const requests = new RequestStore(config.dataDir);
|
|
49
|
+
try {
|
|
50
|
+
servers.verifyVault();
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
logger.error(err.message);
|
|
54
|
+
process.stderr.write(`[mcp-ssh-gate] ${err.message}\n`);
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
let guiStatus = "stopped";
|
|
58
|
+
let ownGuiUrl = null;
|
|
59
|
+
let guiStartWaiter = null;
|
|
60
|
+
function handleIpcMessage(msg) {
|
|
61
|
+
if (!msg || typeof msg.type !== "string")
|
|
62
|
+
return;
|
|
63
|
+
switch (msg.type) {
|
|
64
|
+
case "READY":
|
|
65
|
+
guiStatus = "ready";
|
|
66
|
+
ownGuiUrl = msg.data?.url ?? ownGuiUrl;
|
|
67
|
+
logger.info(`GUI ready at ${ownGuiUrl}`);
|
|
68
|
+
guiStartWaiter?.();
|
|
69
|
+
return;
|
|
70
|
+
case "ALREADY_RUNNING":
|
|
71
|
+
guiStatus = "already_running";
|
|
72
|
+
guiStartWaiter?.();
|
|
73
|
+
return;
|
|
74
|
+
default:
|
|
75
|
+
logger.debug(`Unhandled IPC message type: ${msg.type}`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
const guiLauncher = new GuiLauncher({
|
|
79
|
+
guiPath: path.join(__dirname, "gui-worker.js"),
|
|
80
|
+
env: { ...process.env },
|
|
81
|
+
maxRestarts: 0,
|
|
82
|
+
onMessage: handleIpcMessage,
|
|
83
|
+
});
|
|
84
|
+
function startGuiWorker() {
|
|
85
|
+
guiStatus = "starting";
|
|
86
|
+
const child = guiLauncher.start();
|
|
87
|
+
child.on("exit", () => {
|
|
88
|
+
if (guiLauncher.getProcess() === child && guiStatus !== "already_running")
|
|
89
|
+
guiStatus = "stopped";
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Un seul worker sert l'interface et exécute les commandes pour toutes les
|
|
94
|
+
* sessions d'agent. Il en démarre un quand aucun n'a donné signe de vie.
|
|
95
|
+
*/
|
|
96
|
+
function ensureGui() {
|
|
97
|
+
if (guiStatus === "starting")
|
|
98
|
+
return;
|
|
99
|
+
if (guiStatus === "ready" && guiLauncher.getProcess()?.connected)
|
|
100
|
+
return;
|
|
101
|
+
if (liveGuiUrl(config.dataDir))
|
|
102
|
+
return;
|
|
103
|
+
logger.info("No live GUI worker — starting one.");
|
|
104
|
+
startGuiWorker();
|
|
105
|
+
}
|
|
106
|
+
function guiUrl() {
|
|
107
|
+
return liveGuiUrl(config.dataDir) ?? ownGuiUrl;
|
|
108
|
+
}
|
|
109
|
+
async function reconnectGui() {
|
|
110
|
+
ensureGui();
|
|
111
|
+
if (guiStatus === "starting") {
|
|
112
|
+
await new Promise((resolve) => {
|
|
113
|
+
const timer = setTimeout(resolve, 10_000);
|
|
114
|
+
guiStartWaiter = () => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
resolve();
|
|
117
|
+
};
|
|
118
|
+
});
|
|
119
|
+
guiStartWaiter = null;
|
|
120
|
+
}
|
|
121
|
+
const url = guiUrl();
|
|
122
|
+
if (!url) {
|
|
123
|
+
return { content: [{ type: "text", text: "Error: the GUI did not start. Check the logs in " + config.logDir }], isError: true };
|
|
124
|
+
}
|
|
125
|
+
return { content: [{ type: "text", text: `GUI running at ${url}` }] };
|
|
126
|
+
}
|
|
127
|
+
// --- Serveur MCP ---------------------------------------------------------------
|
|
128
|
+
const toolContext = { servers, requests, ensureGui, guiUrl, reconnectGui };
|
|
129
|
+
const server = new Server({ name: "mcp-ssh-gate", version: APP_VERSION }, { capabilities: { tools: {} } });
|
|
130
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFINITIONS }));
|
|
131
|
+
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
132
|
+
return callTool(toolContext, request.params.name, request.params.arguments, extra.signal);
|
|
133
|
+
});
|
|
134
|
+
async function main() {
|
|
135
|
+
const transport = new StdioServerTransport();
|
|
136
|
+
await server.connect(transport);
|
|
137
|
+
logger.info(`MCP server running on stdio (data dir: ${config.dataDir})`);
|
|
138
|
+
ensureGui();
|
|
139
|
+
}
|
|
140
|
+
main().catch((err) => {
|
|
141
|
+
logger.error(`Fatal error: ${err.message}`);
|
|
142
|
+
process.exit(1);
|
|
143
|
+
});
|
|
144
|
+
process.on("uncaughtException", (err) => {
|
|
145
|
+
logger.error(`Uncaught exception: ${err.message}`);
|
|
146
|
+
logger.error(err.stack || "No stack trace");
|
|
147
|
+
guiLauncher.getProcess()?.kill("SIGTERM");
|
|
148
|
+
process.exit(1);
|
|
149
|
+
});
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { GateRequest } from "../../shared/model.js";
|
|
2
|
+
import type { StoredServer } from "../core/server-store.js";
|
|
3
|
+
/** Demande lisible par l'agent : statut, note de l'utilisateur, commandes avec leur sortie. */
|
|
4
|
+
export declare function formatRequest(request: GateRequest, server: StoredServer | null, maxOutput: number): string;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const STATUS_TEXT = {
|
|
2
|
+
pending: "waiting for the user's approval",
|
|
3
|
+
running: "running",
|
|
4
|
+
completed: "completed",
|
|
5
|
+
failed: "failed",
|
|
6
|
+
interrupted: "interrupted by the user",
|
|
7
|
+
refused: "refused by the user",
|
|
8
|
+
cancelled: "cancelled by you",
|
|
9
|
+
};
|
|
10
|
+
/** Garde la fin d'une sortie : les erreurs sont en général dans les dernières lignes. */
|
|
11
|
+
function tail(text, max) {
|
|
12
|
+
if (text.length <= max)
|
|
13
|
+
return { text, omitted: 0 };
|
|
14
|
+
return { text: text.slice(-max), omitted: text.length - max };
|
|
15
|
+
}
|
|
16
|
+
function duration(command) {
|
|
17
|
+
if (!command.startedAt || !command.endedAt)
|
|
18
|
+
return "";
|
|
19
|
+
const seconds = (Date.parse(command.endedAt) - Date.parse(command.startedAt)) / 1000;
|
|
20
|
+
return ` · ${seconds.toFixed(1)} s`;
|
|
21
|
+
}
|
|
22
|
+
function formatCommand(command, index, maxOutput) {
|
|
23
|
+
const lines = [`${index + 1}. $ ${command.command}`];
|
|
24
|
+
if (command.original !== null)
|
|
25
|
+
lines.push(` Edited by the user. You proposed: $ ${command.original}`);
|
|
26
|
+
switch (command.status) {
|
|
27
|
+
case "skipped":
|
|
28
|
+
lines.push(" Not run.");
|
|
29
|
+
return lines.join("\n");
|
|
30
|
+
case "pending":
|
|
31
|
+
lines.push(" Waiting.");
|
|
32
|
+
return lines.join("\n");
|
|
33
|
+
case "running":
|
|
34
|
+
lines.push(" Running…");
|
|
35
|
+
break;
|
|
36
|
+
case "interrupted":
|
|
37
|
+
lines.push(` Interrupted by the user (Ctrl+C)${command.exitCode === null ? "" : `, exit code ${command.exitCode}`}${duration(command)}`);
|
|
38
|
+
break;
|
|
39
|
+
default:
|
|
40
|
+
lines.push(` ${command.exitCode === null ? "No exit code" : `Exit code ${command.exitCode}`}${duration(command)}`);
|
|
41
|
+
}
|
|
42
|
+
const output = tail(command.output, maxOutput);
|
|
43
|
+
if (output.omitted > 0 || command.outputTruncated)
|
|
44
|
+
lines.push(` [The beginning of the output was omitted; the full stored output is in the GUI.]`);
|
|
45
|
+
lines.push(output.text.trim() ? "```\n" + output.text.replace(/\s+$/, "") + "\n```" : " (no output)");
|
|
46
|
+
return lines.join("\n");
|
|
47
|
+
}
|
|
48
|
+
/** Demande lisible par l'agent : statut, note de l'utilisateur, commandes avec leur sortie. */
|
|
49
|
+
export function formatRequest(request, server, maxOutput) {
|
|
50
|
+
const target = server ? `${server.id} (${server.username}@${server.host})` : request.serverId;
|
|
51
|
+
const author = request.source === "user" ? " — typed by the user" : "";
|
|
52
|
+
const lines = [`Request ${request.id} on ${target}${author}: ${STATUS_TEXT[request.status]}.`];
|
|
53
|
+
if (request.explanation)
|
|
54
|
+
lines.push(`Your explanation: ${request.explanation}`);
|
|
55
|
+
if (request.note)
|
|
56
|
+
lines.push(`${request.status === "refused" ? "Reason given by the user" : "Note from the user"}: ${request.note}`);
|
|
57
|
+
else if (request.status === "refused")
|
|
58
|
+
lines.push("The user gave no reason.");
|
|
59
|
+
if (request.error)
|
|
60
|
+
lines.push(`Execution stopped: ${request.error}`);
|
|
61
|
+
lines.push("");
|
|
62
|
+
request.commands.forEach((command, index) => lines.push(formatCommand(command, index, maxOutput)));
|
|
63
|
+
if (request.cwd)
|
|
64
|
+
lines.push("", `Shell working directory afterwards: ${request.cwd}`);
|
|
65
|
+
return lines.join("\n");
|
|
66
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { CallToolResult, Tool } from "@modelcontextprotocol/sdk/types.js";
|
|
2
|
+
import type { RequestStore } from "../core/request-store.js";
|
|
3
|
+
import type { ServerStore } from "../core/server-store.js";
|
|
4
|
+
export interface ToolContext {
|
|
5
|
+
servers: ServerStore;
|
|
6
|
+
requests: RequestStore;
|
|
7
|
+
/** Démarre le worker de l'interface si aucun n'est vivant. */
|
|
8
|
+
ensureGui: () => void;
|
|
9
|
+
/** URL de l'interface servie par le worker vivant. */
|
|
10
|
+
guiUrl: () => string | null;
|
|
11
|
+
reconnectGui: () => Promise<CallToolResult>;
|
|
12
|
+
}
|
|
13
|
+
export declare const TOOL_DEFINITIONS: Tool[];
|
|
14
|
+
export declare function callTool(ctx: ToolContext, name: string, args: unknown, signal?: AbortSignal): Promise<CallToolResult>;
|