Skip to content

Commit ac1705b

Browse files
committed
feat(mcp): full xray node config support in manage_node
Mirror REST API node fields in MCP manage_node for all node types. Add xray zod schema (transport/security/reality/fingerprint+fingerprintPool/ extraInbounds) plus missing common fields (ssh.privateKey, statsPort, paths, settings, rankingCoefficient, comment). xray update merges per-field via dot-paths to preserve generated reality keys / manualKey.
1 parent ee91b24 commit ac1705b

2 files changed

Lines changed: 88 additions & 3 deletions

File tree

‎src/mcp/tools/nodes.js‎

Lines changed: 87 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,53 @@ const queryNodesSchema = z.object({
4040
includeConfig: z.boolean().default(false).describe('Include generated config for a single node'),
4141
});
4242

43+
// Xray stream/security fields shared by the main inbound and extras. Enums
44+
// mirror xrayConfigSchema/xrayExtraInboundSchema in hyNodeModel.js.
45+
const xrayInboundCommonZ = {
46+
transport: z.enum(['tcp', 'ws', 'grpc', 'xhttp']).optional(),
47+
security: z.enum(['reality', 'tls', 'none']).optional(),
48+
flow: z.string().optional(),
49+
fingerprint: z.string().optional().describe('uTLS fingerprint: chrome, firefox, safari, ios, android, edge, 360, qq, random, randomized'),
50+
fingerprintPool: z.array(z.string()).optional().describe('Set of fingerprints; when non-empty one is picked at random per subscription-cache rebuild (overrides fingerprint)'),
51+
alpn: z.array(z.string()).optional().describe('e.g. ["h3","h2","http/1.1"]'),
52+
realityDest: z.string().optional(),
53+
realitySni: z.array(z.string()).optional(),
54+
realityPrivateKey: z.string().optional(),
55+
realityPublicKey: z.string().optional(),
56+
realityShortIds: z.array(z.string()).optional(),
57+
realitySpiderX: z.string().optional(),
58+
wsPath: z.string().optional(),
59+
wsHost: z.string().optional(),
60+
grpcServiceName: z.string().optional(),
61+
xhttpPath: z.string().optional(),
62+
xhttpHost: z.string().optional(),
63+
xhttpMode: z.enum(['auto', 'packet-up', 'stream-up', 'stream-one']).optional(),
64+
fallbackDest: z.string().optional().describe('VLESS fallbacks[].dest — emitted only on tcp+tls'),
65+
};
66+
67+
const xrayExtraInboundZ = z.object({
68+
...xrayInboundCommonZ,
69+
id: z.string().describe('Stable client-generated uuid tracking the inbound across edits'),
70+
label: z.string().optional(),
71+
uniqueName: z.boolean().optional(),
72+
port: z.number(),
73+
inboundTag: z.string(),
74+
});
75+
76+
const xrayConfigZ = z.object({
77+
...xrayInboundCommonZ,
78+
tlsSource: z.enum(['panel', 'acme', 'manual', 'self-signed']).optional(),
79+
acmeEmail: z.string().optional(),
80+
manualCert: z.string().optional(),
81+
manualKey: z.string().optional(),
82+
apiPort: z.number().optional(),
83+
inboundTag: z.string().optional(),
84+
agentPort: z.number().optional(),
85+
agentToken: z.string().optional(),
86+
agentTls: z.boolean().optional(),
87+
extraInbounds: z.array(xrayExtraInboundZ).optional(),
88+
});
89+
4390
const manageNodeSchema = z.object({
4491
action: z.enum(['create', 'update', 'delete', 'sync', 'setup', 'reset_status', 'update_config', 'setup_port_hopping', 'generate_xray_keys']),
4592
id: z.string().optional().describe('Node MongoDB _id (required for all except create)'),
@@ -76,7 +123,20 @@ const manageNodeSchema = z.object({
76123
port: z.number().optional(),
77124
username: z.string().optional(),
78125
password: z.string().optional(),
126+
privateKey: z.string().optional(),
127+
}).optional(),
128+
statsPort: z.number().optional(),
129+
paths: z.object({
130+
config: z.string().optional(),
131+
cert: z.string().optional(),
132+
key: z.string().optional(),
79133
}).optional(),
134+
settings: z.record(z.unknown()).optional(),
135+
rankingCoefficient: z.number().optional(),
136+
comment: z.string().optional().describe('Free-form operator note (trimmed, max 500 chars)'),
137+
// Xray inbound config (only for type="xray"). On update only the provided
138+
// keys are changed; omit reality keys / manualKey to keep generated values.
139+
xray: xrayConfigZ.optional().describe('Xray inbound config (only for type="xray"). On update only provided keys change; omit realityPrivateKey/realityPublicKey/manualKey to preserve generated values'),
80140
// Hysteria 2 advanced configuration
81141
hopInterval: z.string().optional().describe('Port-hopping interval, e.g. "30s"'),
82142
acme: z.object({
@@ -330,6 +390,15 @@ async function manageNode(args, emit) {
330390
for (const k of hy2Keys) {
331391
if (data[k] !== undefined) nodeData[k] = data[k];
332392
}
393+
394+
// Xray + remaining common fields, mirroring routes/nodes.js POST.
395+
if (nodeType === 'xray' && data.xray) nodeData.xray = data.xray;
396+
if (data.statsPort !== undefined) nodeData.statsPort = data.statsPort;
397+
if (data.paths !== undefined) nodeData.paths = data.paths;
398+
if (data.settings !== undefined) nodeData.settings = data.settings;
399+
if (data.rankingCoefficient !== undefined) nodeData.rankingCoefficient = data.rankingCoefficient;
400+
if (typeof data.comment === 'string') nodeData.comment = data.comment.trim().slice(0, 500);
401+
333402
const node = new HyNode(nodeData);
334403
await node.save();
335404
await invalidateNodesCache();
@@ -340,7 +409,8 @@ async function manageNode(args, emit) {
340409
case 'update': {
341410
if (!id) throw new Error('id is required for update');
342411
const allowed = [
343-
'name', 'domain', 'sni', 'port', 'portRange', 'groups', 'active', 'country', 'cascadeRole', 'type',
412+
'name', 'domain', 'sni', 'port', 'portRange', 'statsPort', 'groups', 'ssh', 'paths',
413+
'settings', 'active', 'rankingCoefficient', 'country', 'comment', 'cascadeRole', 'type',
344414
'virtual',
345415
'hopInterval', 'acme', 'masquerade', 'bandwidth',
346416
'ignoreClientBandwidth', 'speedTest', 'disableUDP',
@@ -349,7 +419,22 @@ async function manageNode(args, emit) {
349419
];
350420
const updates = {};
351421
for (const k of allowed) {
352-
if (data[k] !== undefined) updates[k] = data[k];
422+
if (data[k] === undefined) continue;
423+
if (k === 'ssh') {
424+
updates[k] = cryptoService.encryptSshCredentials(data[k]);
425+
} else if (k === 'comment') {
426+
updates[k] = typeof data[k] === 'string' ? data[k].trim().slice(0, 500) : '';
427+
} else {
428+
updates[k] = data[k];
429+
}
430+
}
431+
432+
// Xray: partial update via dot-paths so unsent secrets (realityPrivateKey,
433+
// realityPublicKey, manualKey) are preserved instead of wiped by a full $set.
434+
if (data.xray && typeof data.xray === 'object') {
435+
for (const [k, v] of Object.entries(data.xray)) {
436+
updates[`xray.${k}`] = v;
437+
}
353438
}
354439

355440
// findByIdAndUpdate skips pre('validate') hooks, so re-implement

‎src/services/mcpService.js‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,7 @@ const TOOLS = {
8585
},
8686

8787
manage_node: {
88-
description: 'Manage Hysteria/Xray/virtual nodes: create, update, delete, sync, auto-setup via SSH, reset status, update config, setup port hopping, generate Xray Reality keys. "virtual" nodes are load-balancer entries over real sibling nodes.',
88+
description: 'Manage Hysteria/Xray/virtual nodes: create, update, delete, sync, auto-setup via SSH, reset status, update config, setup port hopping, generate Xray Reality keys. "virtual" nodes are load-balancer entries over real sibling nodes. For type="xray" pass the full "xray" object (transport, security/reality, fingerprint + fingerprintPool, alpn, ws/grpc/xhttp, extraInbounds); on update the xray object is merged per-field, so omit realityPrivateKey/realityPublicKey/manualKey to keep generated values.',
8989
requiredScope: 'nodes:write',
9090
inputSchema: zodToInputSchema(nodesTools.schemas.manageNode),
9191
},

0 commit comments

Comments
 (0)