@xiaoyankonling/ssh-mcp 2.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/LICENSE +21 -0
- package/README.md +256 -0
- package/build/cli/args.js +75 -0
- package/build/config/loader.js +138 -0
- package/build/config/types.js +75 -0
- package/build/index.js +145 -0
- package/build/profile/profile-manager.js +385 -0
- package/build/ssh/command-utils.js +49 -0
- package/build/ssh/connection-manager.js +371 -0
- package/build/tools/exec.js +39 -0
- package/build/tools/profiles.js +159 -0
- package/build/tools/result.js +8 -0
- package/build/tools/sudo-exec.js +38 -0
- package/package.json +65 -0
package/build/index.js
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFile } from 'fs/promises';
|
|
3
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
5
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
6
|
+
import { determineStartupMode, parseArgv, resolveRuntimeOptions, } from './cli/args.js';
|
|
7
|
+
import { resolveKeyPath } from './config/loader.js';
|
|
8
|
+
import { ProfileManager } from './profile/profile-manager.js';
|
|
9
|
+
import { DEFAULT_MAX_CHARS, DEFAULT_TIMEOUT_MS, sanitizeCommand, sanitizePassword, } from './ssh/command-utils.js';
|
|
10
|
+
import { SSHConnectionManager, execSshCommand, execSshCommandWithConnection, } from './ssh/connection-manager.js';
|
|
11
|
+
import { registerExecTool } from './tools/exec.js';
|
|
12
|
+
import { registerProfileTools } from './tools/profiles.js';
|
|
13
|
+
import { registerSudoExecTool } from './tools/sudo-exec.js';
|
|
14
|
+
const isTestMode = process.env.SSH_MCP_TEST === '1';
|
|
15
|
+
const isCliEnabled = process.env.SSH_MCP_DISABLE_MAIN !== '1';
|
|
16
|
+
const shouldBootServer = isCliEnabled || isTestMode;
|
|
17
|
+
const argvConfig = shouldBootServer ? parseArgv() : {};
|
|
18
|
+
const server = new McpServer({
|
|
19
|
+
name: 'SSH MCP Server',
|
|
20
|
+
version: '1.5.0',
|
|
21
|
+
capabilities: {
|
|
22
|
+
resources: {},
|
|
23
|
+
tools: {},
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
let startupMode = null;
|
|
27
|
+
let profileManager = null;
|
|
28
|
+
let connectionManager = null;
|
|
29
|
+
let initialRuntimeOptions = {
|
|
30
|
+
timeoutMs: DEFAULT_TIMEOUT_MS,
|
|
31
|
+
maxChars: DEFAULT_MAX_CHARS,
|
|
32
|
+
disableSudo: false,
|
|
33
|
+
};
|
|
34
|
+
function getRuntimeOptions() {
|
|
35
|
+
if (profileManager) {
|
|
36
|
+
return resolveRuntimeOptions(argvConfig, profileManager.getDefaults());
|
|
37
|
+
}
|
|
38
|
+
return initialRuntimeOptions;
|
|
39
|
+
}
|
|
40
|
+
async function closeConnectionManager() {
|
|
41
|
+
if (!connectionManager)
|
|
42
|
+
return;
|
|
43
|
+
connectionManager.close();
|
|
44
|
+
connectionManager = null;
|
|
45
|
+
}
|
|
46
|
+
async function buildSshConfig(mode) {
|
|
47
|
+
if (!profileManager) {
|
|
48
|
+
throw new McpError(ErrorCode.InternalError, 'Profile mode not initialized');
|
|
49
|
+
}
|
|
50
|
+
const profile = profileManager.getActiveProfile();
|
|
51
|
+
const sshConfig = {
|
|
52
|
+
host: profile.host,
|
|
53
|
+
port: profile.port,
|
|
54
|
+
username: profile.user,
|
|
55
|
+
};
|
|
56
|
+
if (profile.auth.type === 'password') {
|
|
57
|
+
sshConfig.password = sanitizePassword(profile.auth.password);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
const keyPath = resolveKeyPath(profile.auth.keyPath, profileManager.getConfigPath());
|
|
61
|
+
sshConfig.privateKey = await readFile(keyPath, 'utf8');
|
|
62
|
+
}
|
|
63
|
+
if (profile.suPassword !== undefined) {
|
|
64
|
+
sshConfig.suPassword = sanitizePassword(profile.suPassword);
|
|
65
|
+
}
|
|
66
|
+
if (profile.sudoPassword !== undefined) {
|
|
67
|
+
sshConfig.sudoPassword = sanitizePassword(profile.sudoPassword);
|
|
68
|
+
}
|
|
69
|
+
return sshConfig;
|
|
70
|
+
}
|
|
71
|
+
async function getConnectionManager() {
|
|
72
|
+
if (!startupMode) {
|
|
73
|
+
throw new McpError(ErrorCode.InternalError, 'Server startup mode is not initialized');
|
|
74
|
+
}
|
|
75
|
+
if (connectionManager) {
|
|
76
|
+
return connectionManager;
|
|
77
|
+
}
|
|
78
|
+
const sshConfig = await buildSshConfig(startupMode);
|
|
79
|
+
connectionManager = new SSHConnectionManager(sshConfig);
|
|
80
|
+
return connectionManager;
|
|
81
|
+
}
|
|
82
|
+
async function initializeRuntime() {
|
|
83
|
+
startupMode = determineStartupMode(argvConfig);
|
|
84
|
+
profileManager = new ProfileManager(startupMode.configPath, startupMode.profileIdOverride);
|
|
85
|
+
await profileManager.initialize();
|
|
86
|
+
initialRuntimeOptions = resolveRuntimeOptions(argvConfig, profileManager.getDefaults());
|
|
87
|
+
}
|
|
88
|
+
function registerTools() {
|
|
89
|
+
registerExecTool(server, {
|
|
90
|
+
getConnectionManager,
|
|
91
|
+
getRuntimeOptions,
|
|
92
|
+
});
|
|
93
|
+
if (!getRuntimeOptions().disableSudo) {
|
|
94
|
+
registerSudoExecTool(server, {
|
|
95
|
+
getConnectionManager,
|
|
96
|
+
getRuntimeOptions,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
if (profileManager) {
|
|
100
|
+
registerProfileTools(server, {
|
|
101
|
+
profileManager,
|
|
102
|
+
onTargetChanged: closeConnectionManager,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function bootstrapServer() {
|
|
107
|
+
await initializeRuntime();
|
|
108
|
+
registerTools();
|
|
109
|
+
}
|
|
110
|
+
async function runMain() {
|
|
111
|
+
await bootstrapServer();
|
|
112
|
+
const transport = new StdioServerTransport();
|
|
113
|
+
await server.connect(transport);
|
|
114
|
+
console.error('SSH MCP Server running on stdio');
|
|
115
|
+
const cleanup = () => {
|
|
116
|
+
closeConnectionManager()
|
|
117
|
+
.catch(() => undefined)
|
|
118
|
+
.finally(() => process.exit(0));
|
|
119
|
+
};
|
|
120
|
+
process.on('SIGINT', cleanup);
|
|
121
|
+
process.on('SIGTERM', cleanup);
|
|
122
|
+
process.on('exit', () => {
|
|
123
|
+
if (connectionManager) {
|
|
124
|
+
connectionManager.close();
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
if (isTestMode) {
|
|
129
|
+
bootstrapServer()
|
|
130
|
+
.then(async () => {
|
|
131
|
+
const transport = new StdioServerTransport();
|
|
132
|
+
await server.connect(transport);
|
|
133
|
+
})
|
|
134
|
+
.catch((error) => {
|
|
135
|
+
console.error('Fatal error connecting server:', error);
|
|
136
|
+
process.exit(1);
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
else if (isCliEnabled) {
|
|
140
|
+
runMain().catch((error) => {
|
|
141
|
+
console.error('Fatal error in main():', error);
|
|
142
|
+
closeConnectionManager().finally(() => process.exit(1));
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
export { parseArgv, sanitizeCommand, SSHConnectionManager, execSshCommandWithConnection, execSshCommand, };
|
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
import { loadProfilesConfig, profileSummary, saveRawProfilesConfig, validateRawProfilesConfig, } from '../config/loader.js';
|
|
2
|
+
import { profileSchema } from '../config/types.js';
|
|
3
|
+
import { chmod, mkdir, readFile, writeFile } from 'fs/promises';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { randomUUID } from 'crypto';
|
|
6
|
+
function hasProfile(profiles, profileId) {
|
|
7
|
+
return profiles.some((profile) => profile.id === profileId);
|
|
8
|
+
}
|
|
9
|
+
function assertObject(value, message) {
|
|
10
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
11
|
+
throw new Error(message);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function cloneRawConfig(rawConfig) {
|
|
15
|
+
return JSON.parse(JSON.stringify(rawConfig));
|
|
16
|
+
}
|
|
17
|
+
function sanitizeBackupReason(reason) {
|
|
18
|
+
const sanitized = reason
|
|
19
|
+
.replace(/[^a-zA-Z0-9._-]+/g, '_')
|
|
20
|
+
.replace(/\.\.+/g, '_')
|
|
21
|
+
.replace(/_+/g, '_')
|
|
22
|
+
.replace(/^_+|_+$/g, '');
|
|
23
|
+
if (sanitized.length === 0)
|
|
24
|
+
return 'backup';
|
|
25
|
+
return sanitized.slice(0, 80);
|
|
26
|
+
}
|
|
27
|
+
function readPersistedActiveProfileId(rawConfig, fallback) {
|
|
28
|
+
const rawActive = rawConfig.activeProfile;
|
|
29
|
+
if (typeof rawActive === 'string' && rawActive.trim().length > 0) {
|
|
30
|
+
return rawActive;
|
|
31
|
+
}
|
|
32
|
+
return fallback;
|
|
33
|
+
}
|
|
34
|
+
function toShortNote(input) {
|
|
35
|
+
const normalized = input.replace(/\s+/g, ' ').trim();
|
|
36
|
+
if (normalized.length <= 120)
|
|
37
|
+
return normalized;
|
|
38
|
+
return `${normalized.slice(0, 117)}...`;
|
|
39
|
+
}
|
|
40
|
+
function defaultNoteForProfile(input) {
|
|
41
|
+
if (input.note && input.note.trim().length > 0) {
|
|
42
|
+
return toShortNote(input.note);
|
|
43
|
+
}
|
|
44
|
+
const hints = [];
|
|
45
|
+
if (input.contextSummary && input.contextSummary.trim().length > 0) {
|
|
46
|
+
hints.push(input.contextSummary.trim());
|
|
47
|
+
}
|
|
48
|
+
if (input.tags && input.tags.length > 0) {
|
|
49
|
+
hints.push(`tags:${input.tags.join(',')}`);
|
|
50
|
+
}
|
|
51
|
+
hints.push(`${input.name}(${input.host})`);
|
|
52
|
+
return toShortNote(hints.join(' | '));
|
|
53
|
+
}
|
|
54
|
+
export class ProfileManager {
|
|
55
|
+
loadedConfig = null;
|
|
56
|
+
activeProfileId = null;
|
|
57
|
+
profileOverride;
|
|
58
|
+
configPath;
|
|
59
|
+
pendingDeletes = new Map();
|
|
60
|
+
constructor(configPath, profileOverride) {
|
|
61
|
+
this.configPath = configPath;
|
|
62
|
+
this.profileOverride = profileOverride;
|
|
63
|
+
}
|
|
64
|
+
async initialize() {
|
|
65
|
+
const loaded = await loadProfilesConfig(this.configPath);
|
|
66
|
+
this.loadedConfig = loaded;
|
|
67
|
+
this.activeProfileId = this.pickActiveProfileId(loaded, this.profileOverride);
|
|
68
|
+
}
|
|
69
|
+
ensureLoaded() {
|
|
70
|
+
if (!this.loadedConfig || !this.activeProfileId) {
|
|
71
|
+
throw new Error('ProfileManager is not initialized');
|
|
72
|
+
}
|
|
73
|
+
return this.loadedConfig;
|
|
74
|
+
}
|
|
75
|
+
pickActiveProfileId(loaded, preferred) {
|
|
76
|
+
const candidates = [preferred, this.profileOverride, loaded.config.activeProfile];
|
|
77
|
+
for (const candidate of candidates) {
|
|
78
|
+
if (!candidate)
|
|
79
|
+
continue;
|
|
80
|
+
if (hasProfile(loaded.config.profiles, candidate)) {
|
|
81
|
+
return candidate;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
throw new Error('Unable to resolve an active profile from configuration');
|
|
85
|
+
}
|
|
86
|
+
getRawProfiles(rawConfig) {
|
|
87
|
+
const rawProfiles = rawConfig.profiles;
|
|
88
|
+
if (!Array.isArray(rawProfiles)) {
|
|
89
|
+
throw new Error('Invalid raw config: profiles is not an array');
|
|
90
|
+
}
|
|
91
|
+
for (const item of rawProfiles) {
|
|
92
|
+
assertObject(item, 'Invalid raw profile item');
|
|
93
|
+
}
|
|
94
|
+
return rawProfiles;
|
|
95
|
+
}
|
|
96
|
+
findRawProfile(rawConfig, profileId) {
|
|
97
|
+
const rawProfiles = this.getRawProfiles(rawConfig);
|
|
98
|
+
return rawProfiles.find((item) => item.id === profileId);
|
|
99
|
+
}
|
|
100
|
+
async persistRawConfig(rawConfig, preferredActiveId) {
|
|
101
|
+
const currentLoaded = this.ensureLoaded();
|
|
102
|
+
const parsedConfig = await validateRawProfilesConfig(rawConfig, this.configPath);
|
|
103
|
+
const nextLoaded = {
|
|
104
|
+
...currentLoaded,
|
|
105
|
+
rawConfig,
|
|
106
|
+
config: parsedConfig,
|
|
107
|
+
};
|
|
108
|
+
await saveRawProfilesConfig(nextLoaded);
|
|
109
|
+
this.loadedConfig = nextLoaded;
|
|
110
|
+
this.activeProfileId = this.pickActiveProfileId(nextLoaded, preferredActiveId ?? this.activeProfileId ?? undefined);
|
|
111
|
+
}
|
|
112
|
+
getConfigPath() {
|
|
113
|
+
return this.configPath;
|
|
114
|
+
}
|
|
115
|
+
getActiveProfileId() {
|
|
116
|
+
this.ensureLoaded();
|
|
117
|
+
return this.activeProfileId;
|
|
118
|
+
}
|
|
119
|
+
getActiveProfile() {
|
|
120
|
+
const loaded = this.ensureLoaded();
|
|
121
|
+
const activeId = this.activeProfileId;
|
|
122
|
+
const profile = loaded.config.profiles.find((item) => item.id === activeId);
|
|
123
|
+
if (!profile) {
|
|
124
|
+
throw new Error(`Active profile "${activeId}" not found`);
|
|
125
|
+
}
|
|
126
|
+
return profile;
|
|
127
|
+
}
|
|
128
|
+
getDefaults() {
|
|
129
|
+
const loaded = this.ensureLoaded();
|
|
130
|
+
return loaded.config.defaults ?? {};
|
|
131
|
+
}
|
|
132
|
+
listProfiles() {
|
|
133
|
+
const loaded = this.ensureLoaded();
|
|
134
|
+
const activeId = this.activeProfileId;
|
|
135
|
+
return loaded.config.profiles.map((profile) => profileSummary(profile, activeId));
|
|
136
|
+
}
|
|
137
|
+
useProfile(profileId) {
|
|
138
|
+
const loaded = this.ensureLoaded();
|
|
139
|
+
const profile = loaded.config.profiles.find((item) => item.id === profileId);
|
|
140
|
+
if (!profile) {
|
|
141
|
+
throw new Error(`Profile "${profileId}" does not exist`);
|
|
142
|
+
}
|
|
143
|
+
this.activeProfileId = profileId;
|
|
144
|
+
return profileSummary(profile, profileId);
|
|
145
|
+
}
|
|
146
|
+
async setActiveProfile(profileId, persist = true) {
|
|
147
|
+
const profile = this.getProfile(profileId);
|
|
148
|
+
if (persist) {
|
|
149
|
+
const loaded = this.ensureLoaded();
|
|
150
|
+
const nextRawConfig = cloneRawConfig(loaded.rawConfig);
|
|
151
|
+
nextRawConfig.activeProfile = profileId;
|
|
152
|
+
await this.persistRawConfig(nextRawConfig, profileId);
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
this.activeProfileId = profileId;
|
|
156
|
+
}
|
|
157
|
+
return profileSummary(profile, this.getActiveProfileId());
|
|
158
|
+
}
|
|
159
|
+
findProfiles(query) {
|
|
160
|
+
const normalized = query.trim().toLowerCase();
|
|
161
|
+
const all = this.listProfiles();
|
|
162
|
+
if (!normalized) {
|
|
163
|
+
return all;
|
|
164
|
+
}
|
|
165
|
+
const scored = all
|
|
166
|
+
.map((profile) => {
|
|
167
|
+
const record = profile;
|
|
168
|
+
const fields = {
|
|
169
|
+
id: String(record.id ?? '').toLowerCase(),
|
|
170
|
+
name: String(record.name ?? '').toLowerCase(),
|
|
171
|
+
host: String(record.host ?? '').toLowerCase(),
|
|
172
|
+
user: String(record.user ?? '').toLowerCase(),
|
|
173
|
+
note: String(record.note ?? '').toLowerCase(),
|
|
174
|
+
tags: Array.isArray(record.tags) ? record.tags.map((item) => item.toLowerCase()) : [],
|
|
175
|
+
};
|
|
176
|
+
let score = 0;
|
|
177
|
+
if (fields.id === normalized)
|
|
178
|
+
score += 100;
|
|
179
|
+
if (fields.id.includes(normalized))
|
|
180
|
+
score += 50;
|
|
181
|
+
if (fields.host === normalized)
|
|
182
|
+
score += 40;
|
|
183
|
+
if (fields.host.includes(normalized))
|
|
184
|
+
score += 20;
|
|
185
|
+
if (fields.name.includes(normalized))
|
|
186
|
+
score += 15;
|
|
187
|
+
if (fields.user.includes(normalized))
|
|
188
|
+
score += 10;
|
|
189
|
+
if (fields.note.includes(normalized))
|
|
190
|
+
score += 8;
|
|
191
|
+
if (fields.tags.some((tag) => tag.includes(normalized)))
|
|
192
|
+
score += 12;
|
|
193
|
+
return {
|
|
194
|
+
score,
|
|
195
|
+
profile: record,
|
|
196
|
+
};
|
|
197
|
+
})
|
|
198
|
+
.filter((item) => item.score > 0)
|
|
199
|
+
.sort((a, b) => b.score - a.score)
|
|
200
|
+
.map((item) => ({
|
|
201
|
+
...item.profile,
|
|
202
|
+
matchScore: item.score,
|
|
203
|
+
}));
|
|
204
|
+
return scored;
|
|
205
|
+
}
|
|
206
|
+
async reload() {
|
|
207
|
+
const previousActiveId = this.activeProfileId ?? undefined;
|
|
208
|
+
const loaded = await loadProfilesConfig(this.configPath);
|
|
209
|
+
this.loadedConfig = loaded;
|
|
210
|
+
this.activeProfileId = this.pickActiveProfileId(loaded, previousActiveId);
|
|
211
|
+
return {
|
|
212
|
+
configPath: this.configPath,
|
|
213
|
+
activeProfile: this.activeProfileId,
|
|
214
|
+
profileCount: loaded.config.profiles.length,
|
|
215
|
+
profiles: this.listProfiles(),
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
async updateNote(profileId, note) {
|
|
219
|
+
this.getProfile(profileId);
|
|
220
|
+
const loaded = this.ensureLoaded();
|
|
221
|
+
const nextRawConfig = cloneRawConfig(loaded.rawConfig);
|
|
222
|
+
const rawProfile = this.findRawProfile(nextRawConfig, profileId);
|
|
223
|
+
if (!rawProfile) {
|
|
224
|
+
throw new Error(`Raw config profile "${profileId}" not found`);
|
|
225
|
+
}
|
|
226
|
+
rawProfile.note = note;
|
|
227
|
+
await this.persistRawConfig(nextRawConfig);
|
|
228
|
+
return profileSummary(this.getProfile(profileId), this.getActiveProfileId());
|
|
229
|
+
}
|
|
230
|
+
async createProfile(input) {
|
|
231
|
+
const loaded = this.ensureLoaded();
|
|
232
|
+
if (this.getProfileMaybe(input.id)) {
|
|
233
|
+
throw new Error(`Profile "${input.id}" already exists`);
|
|
234
|
+
}
|
|
235
|
+
const parsed = profileSchema.parse({
|
|
236
|
+
id: input.id,
|
|
237
|
+
name: input.name,
|
|
238
|
+
host: input.host,
|
|
239
|
+
port: input.port ?? 22,
|
|
240
|
+
user: input.user,
|
|
241
|
+
auth: input.auth,
|
|
242
|
+
suPassword: input.suPassword,
|
|
243
|
+
sudoPassword: input.sudoPassword,
|
|
244
|
+
note: defaultNoteForProfile(input),
|
|
245
|
+
tags: input.tags ?? [],
|
|
246
|
+
});
|
|
247
|
+
const nextRawConfig = cloneRawConfig(loaded.rawConfig);
|
|
248
|
+
const rawProfiles = this.getRawProfiles(nextRawConfig);
|
|
249
|
+
const rawProfile = {
|
|
250
|
+
id: input.id,
|
|
251
|
+
name: input.name,
|
|
252
|
+
host: input.host,
|
|
253
|
+
port: input.port ?? 22,
|
|
254
|
+
user: input.user,
|
|
255
|
+
auth: input.auth.type === 'password'
|
|
256
|
+
? { type: 'password', password: input.auth.password }
|
|
257
|
+
: { type: 'key', keyPath: input.auth.keyPath },
|
|
258
|
+
note: parsed.note,
|
|
259
|
+
tags: input.tags ?? [],
|
|
260
|
+
};
|
|
261
|
+
if (input.suPassword)
|
|
262
|
+
rawProfile.suPassword = input.suPassword;
|
|
263
|
+
if (input.sudoPassword)
|
|
264
|
+
rawProfile.sudoPassword = input.sudoPassword;
|
|
265
|
+
rawProfiles.push(rawProfile);
|
|
266
|
+
const shouldActivate = input.activate ?? true;
|
|
267
|
+
if (shouldActivate) {
|
|
268
|
+
nextRawConfig.activeProfile = input.id;
|
|
269
|
+
}
|
|
270
|
+
await this.persistRawConfig(nextRawConfig, shouldActivate ? input.id : this.activeProfileId ?? undefined);
|
|
271
|
+
return profileSummary(this.getProfile(input.id), this.getActiveProfileId());
|
|
272
|
+
}
|
|
273
|
+
async prepareDeleteProfile(profileId) {
|
|
274
|
+
const loaded = this.ensureLoaded();
|
|
275
|
+
const profile = this.getProfile(profileId);
|
|
276
|
+
if (loaded.config.profiles.length <= 1) {
|
|
277
|
+
throw new Error('Cannot delete the last profile in config');
|
|
278
|
+
}
|
|
279
|
+
const backupPath = await this.backupCurrentConfig(`delete-${profileId}`);
|
|
280
|
+
const createdAt = Date.now();
|
|
281
|
+
const expiresAt = createdAt + 10 * 60 * 1000;
|
|
282
|
+
const requestId = randomUUID();
|
|
283
|
+
this.pendingDeletes.set(requestId, {
|
|
284
|
+
requestId,
|
|
285
|
+
profileId,
|
|
286
|
+
backupPath,
|
|
287
|
+
createdAt,
|
|
288
|
+
expiresAt,
|
|
289
|
+
});
|
|
290
|
+
return {
|
|
291
|
+
deleteRequestId: requestId,
|
|
292
|
+
profile: profileSummary(profile, this.getActiveProfileId()),
|
|
293
|
+
backupPath,
|
|
294
|
+
expiresAt: new Date(expiresAt).toISOString(),
|
|
295
|
+
confirmationText: `DELETE ${profileId}`,
|
|
296
|
+
warning: 'Deletion is destructive. Confirm with the user before calling profiles-delete-confirm.',
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
async confirmDeleteProfile(deleteRequestId, profileId, confirmationText) {
|
|
300
|
+
const pending = this.pendingDeletes.get(deleteRequestId);
|
|
301
|
+
if (!pending) {
|
|
302
|
+
throw new Error(`Delete request "${deleteRequestId}" does not exist or expired`);
|
|
303
|
+
}
|
|
304
|
+
if (pending.expiresAt < Date.now()) {
|
|
305
|
+
this.pendingDeletes.delete(deleteRequestId);
|
|
306
|
+
throw new Error(`Delete request "${deleteRequestId}" has expired`);
|
|
307
|
+
}
|
|
308
|
+
if (pending.profileId !== profileId) {
|
|
309
|
+
throw new Error('Delete request profile mismatch');
|
|
310
|
+
}
|
|
311
|
+
const expected = `DELETE ${profileId}`;
|
|
312
|
+
if (confirmationText.trim() !== expected) {
|
|
313
|
+
throw new Error(`Invalid confirmationText. Expected exactly: "${expected}"`);
|
|
314
|
+
}
|
|
315
|
+
const loaded = this.ensureLoaded();
|
|
316
|
+
const profileToDelete = this.getProfile(profileId);
|
|
317
|
+
const nextProfiles = loaded.config.profiles.filter((item) => item.id !== profileId);
|
|
318
|
+
if (nextProfiles.length === 0) {
|
|
319
|
+
throw new Error('Cannot delete the last profile in config');
|
|
320
|
+
}
|
|
321
|
+
const runtimeActiveId = this.getActiveProfileId();
|
|
322
|
+
const persistedActiveId = readPersistedActiveProfileId(loaded.rawConfig, loaded.config.activeProfile);
|
|
323
|
+
const nextPersistedActiveId = persistedActiveId === profileId
|
|
324
|
+
? nextProfiles[0].id
|
|
325
|
+
: persistedActiveId;
|
|
326
|
+
const normalizedPersistedActiveId = hasProfile(nextProfiles, nextPersistedActiveId)
|
|
327
|
+
? nextPersistedActiveId
|
|
328
|
+
: nextProfiles[0].id;
|
|
329
|
+
const nextRuntimeActiveId = runtimeActiveId === profileId
|
|
330
|
+
? normalizedPersistedActiveId
|
|
331
|
+
: runtimeActiveId;
|
|
332
|
+
const normalizedRuntimeActiveId = hasProfile(nextProfiles, nextRuntimeActiveId)
|
|
333
|
+
? nextRuntimeActiveId
|
|
334
|
+
: normalizedPersistedActiveId;
|
|
335
|
+
const nextRawConfig = cloneRawConfig(loaded.rawConfig);
|
|
336
|
+
const rawProfiles = this.getRawProfiles(nextRawConfig);
|
|
337
|
+
const filteredRawProfiles = rawProfiles.filter((item) => item.id !== profileId);
|
|
338
|
+
if (filteredRawProfiles.length === rawProfiles.length) {
|
|
339
|
+
throw new Error(`Raw config profile "${profileId}" not found`);
|
|
340
|
+
}
|
|
341
|
+
nextRawConfig.profiles = filteredRawProfiles;
|
|
342
|
+
nextRawConfig.activeProfile = normalizedPersistedActiveId;
|
|
343
|
+
await this.persistRawConfig(nextRawConfig, normalizedRuntimeActiveId);
|
|
344
|
+
this.pendingDeletes.delete(deleteRequestId);
|
|
345
|
+
return {
|
|
346
|
+
deletedProfileId: profileId,
|
|
347
|
+
deletedProfile: profileSummary(profileToDelete, normalizedRuntimeActiveId),
|
|
348
|
+
activeProfile: normalizedRuntimeActiveId,
|
|
349
|
+
persistedActiveProfile: normalizedPersistedActiveId,
|
|
350
|
+
backupPath: pending.backupPath,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
getProfile(profileId) {
|
|
354
|
+
const loaded = this.ensureLoaded();
|
|
355
|
+
const profile = loaded.config.profiles.find((item) => item.id === profileId);
|
|
356
|
+
if (!profile) {
|
|
357
|
+
throw new Error(`Profile "${profileId}" does not exist`);
|
|
358
|
+
}
|
|
359
|
+
return profile;
|
|
360
|
+
}
|
|
361
|
+
getProfileMaybe(profileId) {
|
|
362
|
+
const loaded = this.ensureLoaded();
|
|
363
|
+
return loaded.config.profiles.find((item) => item.id === profileId);
|
|
364
|
+
}
|
|
365
|
+
async backupCurrentConfig(reason) {
|
|
366
|
+
const directory = path.dirname(this.configPath);
|
|
367
|
+
const fileName = path.basename(this.configPath);
|
|
368
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
369
|
+
const safeReason = sanitizeBackupReason(reason);
|
|
370
|
+
const backupDir = path.resolve(directory, '.ssh-mcp-backups');
|
|
371
|
+
await mkdir(backupDir, { recursive: true, mode: 0o700 });
|
|
372
|
+
const backupFileName = `${fileName}.${stamp}.${safeReason}.bak`;
|
|
373
|
+
const backupPath = path.resolve(backupDir, backupFileName);
|
|
374
|
+
const relativePath = path.relative(backupDir, backupPath);
|
|
375
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
376
|
+
throw new Error('Backup path escaped backup directory');
|
|
377
|
+
}
|
|
378
|
+
const content = await readFile(this.configPath, 'utf8');
|
|
379
|
+
await writeFile(backupPath, content, { encoding: 'utf8', mode: 0o600 });
|
|
380
|
+
if (process.platform !== 'win32') {
|
|
381
|
+
await chmod(backupPath, 0o600);
|
|
382
|
+
}
|
|
383
|
+
return backupPath;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
+
export const DEFAULT_MAX_CHARS = 1000;
|
|
3
|
+
export const DEFAULT_TIMEOUT_MS = 60000;
|
|
4
|
+
export function parseMaxChars(value, fallback = DEFAULT_MAX_CHARS) {
|
|
5
|
+
if (value === undefined || value === null)
|
|
6
|
+
return fallback;
|
|
7
|
+
if (typeof value === 'number') {
|
|
8
|
+
if (!Number.isFinite(value))
|
|
9
|
+
return fallback;
|
|
10
|
+
if (value <= 0)
|
|
11
|
+
return Infinity;
|
|
12
|
+
return Math.floor(value);
|
|
13
|
+
}
|
|
14
|
+
if (typeof value === 'string') {
|
|
15
|
+
const trimmed = value.trim().toLowerCase();
|
|
16
|
+
if (trimmed === 'none')
|
|
17
|
+
return Infinity;
|
|
18
|
+
const parsed = Number.parseInt(trimmed, 10);
|
|
19
|
+
if (Number.isNaN(parsed))
|
|
20
|
+
return fallback;
|
|
21
|
+
if (parsed <= 0)
|
|
22
|
+
return Infinity;
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
return fallback;
|
|
26
|
+
}
|
|
27
|
+
export function sanitizeCommand(command, maxChars = DEFAULT_MAX_CHARS) {
|
|
28
|
+
if (typeof command !== 'string') {
|
|
29
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command must be a string');
|
|
30
|
+
}
|
|
31
|
+
const trimmedCommand = command.trim();
|
|
32
|
+
if (!trimmedCommand) {
|
|
33
|
+
throw new McpError(ErrorCode.InvalidParams, 'Command cannot be empty');
|
|
34
|
+
}
|
|
35
|
+
if (Number.isFinite(maxChars) && trimmedCommand.length > maxChars) {
|
|
36
|
+
throw new McpError(ErrorCode.InvalidParams, `Command is too long (max ${maxChars} characters)`);
|
|
37
|
+
}
|
|
38
|
+
return trimmedCommand;
|
|
39
|
+
}
|
|
40
|
+
export function sanitizePassword(password) {
|
|
41
|
+
if (typeof password !== 'string')
|
|
42
|
+
return undefined;
|
|
43
|
+
if (password.length === 0)
|
|
44
|
+
return undefined;
|
|
45
|
+
return password;
|
|
46
|
+
}
|
|
47
|
+
export function escapeCommandForShell(command) {
|
|
48
|
+
return command.replace(/'/g, "'\"'\"'");
|
|
49
|
+
}
|