Skip to content

Commit 376a7d1

Browse files
Codebase audit: bug fixes and robustness improvements for the Remote SSH MCP server (#1)
* Fix remote_browse_dir schema: path is optional (defaults to /home) The input schema declared 'path' as required while the handler defaulted missing values to /home. The two were contradictory; the handler default matches the documented behavior and is friendlier for the folder picker. Drop the bogus required entry so a missing path no longer trips client-side validation. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Allow remote_select_workspace to persist env-only hosts REMOTE_SSH_HOSTS is documented as a valid host source, but selectWorkspaceInConfig threw 'does not exist' whenever the host was loaded only from the environment (or any source other than the writable config file). Pass the resolved host profile as a seed so the writable config can be populated transparently when the alias is missing on disk. The pre-existing 'does not exist' behavior is preserved when no seed is supplied, which keeps the existing test contract intact. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Harden remote_write_file overwrite handling * Remove the dead 'operator' ternary that always evaluated to '>'. * Replace the two-stage 'test ! -e PATH && >' guard with POSIX noclobber ('set -C'), which makes the no-overwrite case atomic with respect to the redirection rather than racing between the existence probe and the write. * Reject non-string content with a clear error instead of letting Buffer.from(undefined) bubble up as 'first argument must be ...'. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Make audit() resilient to filesystem failures Audit logging is a side-channel for operations teams; a missing parent directory or unwritable path should not break the primary SSH tool call. Create the parent directory before writing, and swallow append failures to stderr instead of throwing into the JSON-RPC handler. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Return proper JSON-RPC errors instead of silently succeeding * Unknown methods now produce a -32601 'Method not found' error rather than an empty success response, so clients can tell when a tool was mistyped or removed. * JSON parse failures emit a -32700 error with id: null per JSON-RPC 2.0, instead of being dropped on the floor when the original line could not be parsed. * Plumb error.code through writeError so handlers can attach meaningful codes (parse, method-not-found, etc.) while preserving the existing -32000 default for application errors. * Treat any notifications/* method as a no-op so future MCP notifications do not regress into 'Method not found'. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Harden remote_search_text query handling * Reject empty/non-string queries up front instead of running an effectively-wildcard 'rg ""' that scans the whole tree. * Pass the search pattern via -e and add a '--' separator before the positional target, so queries that begin with '-' (e.g. searching for a flag-like token) are no longer interpreted as ripgrep/grep options. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> * Tighten input validation for remove_host / blocked patterns + tests * remote_remove_host now rejects empty/blank names before consulting the config, instead of producing 'Connection does not exist'. * assertCommandAllowed now ignores malformed blockedCommandPatterns entries rather than letting a bad user-supplied regex crash command execution. * Extend tests to cover: - selectWorkspaceInConfig accepting a seed profile when the alias is not yet in the writable config (the env-var path); - remote_browse_dir input schema no longer marking 'path' as required; - notifications/* still resolving to an empty result; - unknown JSON-RPC methods rejecting with code -32601. Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com> --------- Co-authored-by: MD. Mehedi Hossain <MehediHossain95@users.noreply.github.com>
1 parent 452a055 commit 376a7d1

2 files changed

Lines changed: 89 additions & 22 deletions

File tree

plugins/remote-ssh/scripts/remote-ssh-mcp.js

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,14 @@ function audit(event) {
189189
version: SERVER_VERSION,
190190
...event,
191191
};
192-
fs.appendFileSync(expandHome(auditPath), `${JSON.stringify(record)}\n`);
192+
193+
const resolved = expandHome(auditPath);
194+
try {
195+
fs.mkdirSync(path.dirname(resolved), { recursive: true });
196+
fs.appendFileSync(resolved, `${JSON.stringify(record)}\n`);
197+
} catch (error) {
198+
process.stderr.write(`remote-ssh: audit log write failed (${error.message})\n`);
199+
}
193200
}
194201

195202
function shellQuote(value) {
@@ -261,7 +268,11 @@ function assertCommandAllowed(config, command) {
261268
}
262269

263270
const blocked = (config.blockedCommandPatterns || []).find((pattern) => {
264-
return new RegExp(pattern, "i").test(command);
271+
try {
272+
return new RegExp(pattern, "i").test(command);
273+
} catch {
274+
return false;
275+
}
265276
});
266277
if (blocked) {
267278
throw new Error(`Command denied by policy for host alias ${config.alias}: ${blocked}`);
@@ -403,18 +414,20 @@ function buildBrowseDirCommand(remotePath, limit) {
403414
].join(" && ");
404415
}
405416

406-
function selectWorkspaceInConfig(config, alias, workspacePath) {
407-
if (!config.hosts[alias]) {
417+
function selectWorkspaceInConfig(config, alias, workspacePath, seedProfile) {
418+
const normalized = normalizeRemotePath(workspacePath);
419+
const existing = config.hosts[alias];
420+
if (!existing && !seedProfile) {
408421
throw new Error(`Connection ${alias} does not exist.`);
409422
}
410-
const normalized = normalizeRemotePath(workspacePath);
411-
const host = { ...config.hosts[alias] };
412-
const allowedPaths = Array.isArray(host.allowedPaths) ? [...host.allowedPaths] : [];
423+
const base = existing ? { ...existing } : { ...seedProfile };
424+
delete base.alias;
425+
const allowedPaths = Array.isArray(base.allowedPaths) ? [...base.allowedPaths] : [];
413426
if (!allowedPaths.includes(normalized)) {
414427
allowedPaths.push(normalized);
415428
}
416429
config.hosts[alias] = {
417-
...host,
430+
...base,
418431
workspaceRoot: normalized,
419432
allowedPaths,
420433
};
@@ -696,7 +709,7 @@ const tools = [
696709
path: { type: "string", description: "Absolute remote directory path.", default: "/home" },
697710
limit: { type: "integer", minimum: 1, maximum: 1000, default: 200 },
698711
},
699-
required: ["host", "path"],
712+
required: ["host"],
700713
additionalProperties: false,
701714
},
702715
},
@@ -911,6 +924,9 @@ async function callTool(name, args) {
911924

912925
if (name === "remote_remove_host") {
913926
const alias = String(args.name || "").trim();
927+
if (!alias) {
928+
throw new Error("remote_remove_host requires a non-empty name.");
929+
}
914930
const { file, config } = readWritableConfig();
915931
if (!config.hosts[alias]) {
916932
throw new Error(`Connection ${alias} does not exist in ${file}.`);
@@ -981,7 +997,7 @@ async function callTool(name, args) {
981997
});
982998
}
983999
const { file, config: writableConfig } = readWritableConfig();
984-
const profile = selectWorkspaceInConfig(writableConfig, config.alias, remotePath);
1000+
const profile = selectWorkspaceInConfig(writableConfig, config.alias, remotePath, config);
9851001
writeWritableConfig(file, writableConfig);
9861002
return textResult({
9871003
saved: true,
@@ -1016,15 +1032,20 @@ async function callTool(name, args) {
10161032
return textResult(await runSsh(config, command, name));
10171033
}
10181034
if (name === "remote_search_text") {
1035+
if (typeof args.query !== "string" || args.query.length === 0) {
1036+
throw new Error("remote_search_text requires a non-empty query string.");
1037+
}
10191038
const target = resolveRemoteWorkspacePath(config, args.root, args.path || ".", "search");
10201039
const limit = Math.max(1, Math.min(Number(args.limit || 200), 1000));
1021-
const fixed = args.regex ? "" : "-F";
1040+
const rgMode = args.regex ? "" : "-F ";
10221041
const grepMode = args.regex ? "-E" : "-F";
1042+
const quotedQuery = shellQuote(args.query);
1043+
const quotedTarget = shellQuote(target);
10231044
const command = [
10241045
"if command -v rg >/dev/null 2>&1; then",
1025-
`rg --line-number --hidden --glob '!.git' ${fixed} ${shellQuote(args.query)} ${shellQuote(target)} | head -n ${limit};`,
1046+
`rg --line-number --hidden --glob '!.git' ${rgMode}-e ${quotedQuery} -- ${quotedTarget} | head -n ${limit};`,
10261047
"else",
1027-
`grep -RIn ${grepMode} --exclude-dir=.git ${shellQuote(args.query)} ${shellQuote(target)} | head -n ${limit};`,
1048+
`grep -RIn ${grepMode} --exclude-dir=.git -e ${quotedQuery} -- ${quotedTarget} | head -n ${limit};`,
10281049
"fi",
10291050
].join(" ");
10301051
return textResult(await runSsh(config, command, name));
@@ -1076,11 +1097,15 @@ async function callTool(name, args) {
10761097
if (!config.allowWrites) {
10771098
throw new Error(`Writes are disabled for host alias ${config.alias}. Set allowWrites=true to enable.`);
10781099
}
1100+
if (typeof args.content !== "string") {
1101+
throw new Error("remote_write_file requires content as a UTF-8 string.");
1102+
}
10791103
const remotePath = assertPathAllowed(config, args.path, "write");
10801104
const encoded = Buffer.from(args.content, "utf8").toString("base64");
1081-
const operator = args.overwrite ? ">" : ">";
1082-
const guard = args.overwrite ? "" : `test ! -e ${shellQuote(remotePath)} && `;
1083-
const writeCommand = `${guard}printf %s ${shellQuote(encoded)} | base64 -d ${operator} ${shellQuote(remotePath)}`;
1105+
const quotedPath = shellQuote(remotePath);
1106+
const writeCommand = args.overwrite
1107+
? `printf %s ${shellQuote(encoded)} | base64 -d > ${quotedPath}`
1108+
: `set -C; printf %s ${shellQuote(encoded)} | base64 -d > ${quotedPath}`;
10841109
return textResult(await runSsh(config, writeCommand, name));
10851110
}
10861111

@@ -1127,10 +1152,12 @@ async function handle(request) {
11271152
if (request.method === "tools/call") {
11281153
return callTool(request.params.name, request.params.arguments || {});
11291154
}
1130-
if (request.method === "notifications/initialized") {
1155+
if (request.method && request.method.startsWith("notifications/")) {
11311156
return {};
11321157
}
1133-
return {};
1158+
const err = new Error(`Method not found: ${request.method}`);
1159+
err.code = -32601;
1160+
throw err;
11341161
}
11351162

11361163
function writeResponse(id, result) {
@@ -1139,12 +1166,13 @@ function writeResponse(id, result) {
11391166
}
11401167

11411168
function writeError(id, error) {
1142-
if (id === undefined || id === null) return;
1169+
if (id === undefined) return;
1170+
const code = Number.isInteger(error && error.code) ? error.code : -32000;
11431171
process.stdout.write(
11441172
JSON.stringify({
11451173
jsonrpc: "2.0",
1146-
id,
1147-
error: { code: -32000, message: error.message },
1174+
id: id === undefined ? null : id,
1175+
error: { code, message: error.message },
11481176
}) + "\n",
11491177
);
11501178
}
@@ -1157,10 +1185,17 @@ function startServer() {
11571185
let request;
11581186
try {
11591187
request = JSON.parse(line);
1188+
} catch (error) {
1189+
const parseError = new Error(`Parse error: ${error.message}`);
1190+
parseError.code = -32700;
1191+
writeError(null, parseError);
1192+
return;
1193+
}
1194+
try {
11601195
const result = await handle(request);
11611196
writeResponse(request.id, result);
11621197
} catch (error) {
1163-
writeError(request && request.id, error);
1198+
writeError(request.id, error);
11641199
}
11651200
});
11661201

plugins/remote-ssh/test/remote-ssh-mcp.test.js

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,10 +141,42 @@ assert.deepEqual(selected.allowedPaths, ["/home/mehedi", "/home/mehedi/projects/
141141
assert.equal(selected.identityFile, "~/.ssh/id_ed25519");
142142
assert.throws(() => selectWorkspaceInConfig(writableConfig, "missing", "/home/mehedi"), /does not exist/);
143143

144+
const writableConfigEmpty = { hosts: {} };
145+
const seedProfile = {
146+
alias: "envbox",
147+
user: "envuser",
148+
host: "10.0.0.5",
149+
identityFile: "~/.ssh/id_ed25519",
150+
allowedPaths: ["/srv"],
151+
allowWrites: false,
152+
};
153+
const seededSelection = selectWorkspaceInConfig(writableConfigEmpty, "envbox", "/srv/app", seedProfile);
154+
assert.equal(seededSelection.workspaceRoot, "/srv/app");
155+
assert.deepEqual(seededSelection.allowedPaths, ["/srv", "/srv/app"]);
156+
assert.equal(seededSelection.user, "envuser");
157+
assert.equal(seededSelection.host, "10.0.0.5");
158+
assert.equal(seededSelection.alias, undefined, "alias field should not leak into the saved profile");
159+
160+
const browseDir = tools.find((tool) => tool.name === "remote_browse_dir");
161+
assert.deepEqual(browseDir.inputSchema.required, ["host"], "remote_browse_dir should only require 'host'");
162+
144163
handle({ jsonrpc: "2.0", id: 1, method: "tools/list" }).then((response) => {
145164
assert.ok(response.tools.length >= 20);
146165
return handle({ jsonrpc: "2.0", id: 2, method: "resources/read", params: { uri: "ui://remote-ssh/folder-picker.html" } });
147166
}).then((response) => {
148167
assert.match(response.contents[0].text, /Remote SSH Folder Picker/);
168+
return handle({ jsonrpc: "2.0", id: 3, method: "notifications/initialized" });
169+
}).then((response) => {
170+
assert.deepEqual(response, {}, "notifications/* should return empty result");
171+
return handle({ jsonrpc: "2.0", id: 4, method: "totally/unknown" }).then(
172+
() => {
173+
throw new Error("Unknown methods should throw with -32601");
174+
},
175+
(error) => {
176+
assert.equal(error.code, -32601, "unknown method should carry JSON-RPC -32601 code");
177+
assert.match(error.message, /Method not found/);
178+
},
179+
);
180+
}).then(() => {
149181
console.log("remote-ssh-mcp tests passed");
150182
});

0 commit comments

Comments
 (0)