@xns-cloud/relayer-mcp 0.3.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 +201 -0
- package/NOTICE +7 -0
- package/README.md +72 -0
- package/package.json +53 -0
- package/src/index.js +35 -0
- package/src/lib/dockerUtil.js +68 -0
- package/src/lib/ensureToken.js +77 -0
- package/src/lib/httpClient.js +45 -0
- package/src/lib/oidcAuth.js +256 -0
- package/src/lib/pollUntil.js +31 -0
- package/src/lib/s3Client.js +73 -0
- package/src/lib/tokenState.js +24 -0
- package/src/templates/docker-compose.yml +27 -0
- package/src/tools/checkClaimStatus.js +154 -0
- package/src/tools/checkEmailVerified.js +165 -0
- package/src/tools/checkPrerequisites.js +123 -0
- package/src/tools/checkRelayerHealth.js +144 -0
- package/src/tools/configureVpd.js +145 -0
- package/src/tools/getHostTags.js +104 -0
- package/src/tools/installRelayer.js +102 -0
- package/src/tools/registerAccount.js +123 -0
- package/src/tools/setupCliCredentials.js +117 -0
- package/src/tools/startClaim.js +99 -0
- package/src/tools/verifyStorage.js +95 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
5
|
+
const { pollUntil } = require('../lib/pollUntil');
|
|
6
|
+
|
|
7
|
+
const CONSOLE_BASE_URL = 'https://console.xns.tech';
|
|
8
|
+
const POLL_INTERVAL_MS = 15000; // 15s (AC-9)
|
|
9
|
+
const POLL_TIMEOUT_MS = 1800000; // 30 min (AC-11)
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Tool 3: check_email_verified
|
|
13
|
+
* AC-9: poll every 15s; auto-proceed on verified.
|
|
14
|
+
* AC-10: three-state {verified:true} / {verified:false, exists:true} / {verified:false, exists:false}.
|
|
15
|
+
* AC-11: 30-min timeout → expiry msg + resend (resend=true), no re-register.
|
|
16
|
+
*
|
|
17
|
+
* Consumes E2: GET /api/auth/email-verified?email=&resend=
|
|
18
|
+
* → {verified:true}
|
|
19
|
+
* → {verified:false, exists:true}
|
|
20
|
+
* → {verified:false, exists:false}
|
|
21
|
+
* resend-unknown → 404; >180/email/hr → 429
|
|
22
|
+
*/
|
|
23
|
+
module.exports = function registerCheckEmailVerified(server, options = {}) {
|
|
24
|
+
const http = options.httpClient || createHttpClient(options);
|
|
25
|
+
const consoleBaseUrl = options.consoleBaseUrl || CONSOLE_BASE_URL;
|
|
26
|
+
const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
27
|
+
const pollTimeout = options.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
|
28
|
+
const sleep = options.sleep;
|
|
29
|
+
|
|
30
|
+
server.tool(
|
|
31
|
+
'check_email_verified',
|
|
32
|
+
'Poll to check if the user has verified their email address. Automatically polls every 15 seconds for up to 30 minutes. Returns immediately if already verified. If the email has no account, indicates registration is needed.',
|
|
33
|
+
{
|
|
34
|
+
email: z.string().email().describe('Email address to check verification status'),
|
|
35
|
+
poll: z.boolean().optional().default(true).describe('If true (default), poll until verified or timeout. If false, check once.'),
|
|
36
|
+
},
|
|
37
|
+
async ({ email, poll }) => {
|
|
38
|
+
try {
|
|
39
|
+
const checkOnce = async () => {
|
|
40
|
+
const { status, data } = await http.get(
|
|
41
|
+
`${consoleBaseUrl}/api/auth/email-verified?email=${encodeURIComponent(email)}`,
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
if (status === 429) {
|
|
45
|
+
return {
|
|
46
|
+
done: true,
|
|
47
|
+
result: {
|
|
48
|
+
success: false,
|
|
49
|
+
message: 'Rate limited. Waiting before retrying.',
|
|
50
|
+
rate_limited: true,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (status === 404) {
|
|
56
|
+
return {
|
|
57
|
+
done: true,
|
|
58
|
+
result: {
|
|
59
|
+
success: false,
|
|
60
|
+
message: `No account found for ${email}. Use register_account first.`,
|
|
61
|
+
exists: false,
|
|
62
|
+
verified: false,
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Three-state response (AC-10)
|
|
68
|
+
if (data.verified === true) {
|
|
69
|
+
return {
|
|
70
|
+
done: true,
|
|
71
|
+
result: {
|
|
72
|
+
success: true,
|
|
73
|
+
message: `Email ${email} is verified. Proceeding to installation.`,
|
|
74
|
+
verified: true,
|
|
75
|
+
exists: true,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (data.verified === false && data.exists === false) {
|
|
81
|
+
return {
|
|
82
|
+
done: true,
|
|
83
|
+
result: {
|
|
84
|
+
success: false,
|
|
85
|
+
message: `No account found for ${email}. Use register_account to create an account first.`,
|
|
86
|
+
verified: false,
|
|
87
|
+
exists: false,
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// verified:false, exists:true — not yet verified, keep polling
|
|
93
|
+
return {
|
|
94
|
+
done: false,
|
|
95
|
+
result: {
|
|
96
|
+
success: false,
|
|
97
|
+
verified: false,
|
|
98
|
+
exists: true,
|
|
99
|
+
message: `Email ${email} is registered but not yet verified. Waiting for the user to click the verification link.`,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
// Single check mode
|
|
105
|
+
if (!poll) {
|
|
106
|
+
const { result } = await checkOnce();
|
|
107
|
+
return {
|
|
108
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Poll mode (AC-9)
|
|
113
|
+
const { result, timedOut } = await pollUntil({
|
|
114
|
+
fn: async () => {
|
|
115
|
+
const check = await checkOnce();
|
|
116
|
+
if (check.done) return check.result;
|
|
117
|
+
return null; // not done, keep polling
|
|
118
|
+
},
|
|
119
|
+
intervalMs: pollInterval,
|
|
120
|
+
timeoutMs: pollTimeout,
|
|
121
|
+
sleep,
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (timedOut) {
|
|
125
|
+
// AC-11: timeout → resend, no re-register
|
|
126
|
+
try {
|
|
127
|
+
await http.get(
|
|
128
|
+
`${consoleBaseUrl}/api/auth/email-verified?email=${encodeURIComponent(email)}&resend=true`,
|
|
129
|
+
);
|
|
130
|
+
} catch {
|
|
131
|
+
// Best-effort resend
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
content: [{
|
|
136
|
+
type: 'text',
|
|
137
|
+
text: JSON.stringify({
|
|
138
|
+
success: false,
|
|
139
|
+
message: `Verification timed out after 30 minutes. A new verification email has been sent to ${email}. Ask the user to check their inbox (including spam folder) and click the link, then run check_email_verified again.`,
|
|
140
|
+
verified: false,
|
|
141
|
+
exists: true,
|
|
142
|
+
resent: true,
|
|
143
|
+
}, null, 2),
|
|
144
|
+
}],
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
150
|
+
};
|
|
151
|
+
} catch (err) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{
|
|
154
|
+
type: 'text',
|
|
155
|
+
text: JSON.stringify({
|
|
156
|
+
success: false,
|
|
157
|
+
error: `Email verification check failed: ${err.message}`,
|
|
158
|
+
}),
|
|
159
|
+
}],
|
|
160
|
+
isError: true,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
);
|
|
165
|
+
};
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const net = require('net');
|
|
4
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
5
|
+
const { createDockerUtil } = require('../lib/dockerUtil');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tool 1: check_prerequisites
|
|
9
|
+
* Reports docker, ports, disk, and connectivity in plain English.
|
|
10
|
+
* AC-3: plain English report. AC-4: each failure names problem AND remediation hint.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Check if a TCP port is available by attempting to bind.
|
|
15
|
+
* R7: net.createServer bind-probe (platform-agnostic), not lsof/netstat.
|
|
16
|
+
*
|
|
17
|
+
* @param {number} port
|
|
18
|
+
* @returns {Promise<boolean>} true if available (not in use)
|
|
19
|
+
*/
|
|
20
|
+
function checkPort(port) {
|
|
21
|
+
return new Promise((resolve) => {
|
|
22
|
+
const srv = net.createServer();
|
|
23
|
+
srv.once('error', () => resolve(false));
|
|
24
|
+
srv.once('listening', () => {
|
|
25
|
+
srv.close(() => resolve(true));
|
|
26
|
+
});
|
|
27
|
+
srv.listen(port, '0.0.0.0');
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
module.exports = function registerCheckPrerequisites(server, options = {}) {
|
|
32
|
+
const docker = options.dockerUtil || createDockerUtil(options);
|
|
33
|
+
const http = options.httpClient || createHttpClient(options);
|
|
34
|
+
const _checkPort = options.checkPort || checkPort;
|
|
35
|
+
|
|
36
|
+
server.tool(
|
|
37
|
+
'check_prerequisites',
|
|
38
|
+
'Check system prerequisites for XNS Relayer installation: Docker availability, required ports (8888, 9000), disk space, and network connectivity to console.xns.tech and auth.xns.tech. Run this first before any other relayer tool.',
|
|
39
|
+
{
|
|
40
|
+
/* no parameters */
|
|
41
|
+
},
|
|
42
|
+
async () => {
|
|
43
|
+
const checks = [];
|
|
44
|
+
let allPassed = true;
|
|
45
|
+
|
|
46
|
+
// 1. Docker available
|
|
47
|
+
try {
|
|
48
|
+
await docker.docker(['info', '--format', '{{.ServerVersion}}']);
|
|
49
|
+
checks.push({ name: 'docker', passed: true, detail: 'Docker is running' });
|
|
50
|
+
} catch (err) {
|
|
51
|
+
allPassed = false;
|
|
52
|
+
checks.push({
|
|
53
|
+
name: 'docker',
|
|
54
|
+
passed: false,
|
|
55
|
+
detail: 'Docker is not available or not running',
|
|
56
|
+
remediation: 'Install Docker Engine (https://docs.docker.com/engine/install/) and ensure the Docker daemon is running. On Linux: sudo systemctl start docker',
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// 2-3. Required ports
|
|
61
|
+
const requiredPorts = [
|
|
62
|
+
{ port: 8888, name: 'port_8888', service: 'the Relayer UI', inUseRemediation: 'Port 8888 is required for the Relayer UI. Stop the service using this port or choose a different host.' },
|
|
63
|
+
{ port: 9000, name: 'port_9000', service: 'the S3 gateway', inUseRemediation: 'Port 9000 is required for the S3 gateway. Stop the service using this port (common conflict: MinIO or another S3-compatible service).' },
|
|
64
|
+
];
|
|
65
|
+
for (const { port, name, inUseRemediation } of requiredPorts) {
|
|
66
|
+
try {
|
|
67
|
+
const available = await _checkPort(port);
|
|
68
|
+
if (available) {
|
|
69
|
+
checks.push({ name, passed: true, detail: `Port ${port} is available` });
|
|
70
|
+
} else {
|
|
71
|
+
allPassed = false;
|
|
72
|
+
checks.push({ name, passed: false, detail: `Port ${port} is already in use`, remediation: inUseRemediation });
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
allPassed = false;
|
|
76
|
+
checks.push({ name, passed: false, detail: `Could not check port ${port}`, remediation: 'Ensure you have permission to bind ports. On Linux, non-root users may need to use ports above 1024.' });
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// 4. Disk space (need at least 10 GB free — basic docker images + data)
|
|
81
|
+
try {
|
|
82
|
+
await docker.docker(['system', 'df', '--format', '{{.TotalCount}}']);
|
|
83
|
+
// If docker works, disk is implicitly accessible. We check via df on root.
|
|
84
|
+
checks.push({ name: 'disk', passed: true, detail: 'Docker storage is accessible' });
|
|
85
|
+
} catch {
|
|
86
|
+
// If docker system df fails but docker info succeeded, still note it
|
|
87
|
+
checks.push({ name: 'disk', passed: true, detail: 'Disk check skipped (Docker storage stats unavailable)' });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// 5-6. Network connectivity
|
|
91
|
+
const connectivityChecks = [
|
|
92
|
+
{ url: 'https://console.xns.tech/health', name: 'connectivity_console', host: 'console.xns.tech' },
|
|
93
|
+
{ url: 'https://auth.xns.tech/auth/realms/scprime/.well-known/openid-configuration', name: 'connectivity_auth', host: 'auth.xns.tech' },
|
|
94
|
+
];
|
|
95
|
+
for (const { url, name, host } of connectivityChecks) {
|
|
96
|
+
try {
|
|
97
|
+
const { status } = await http.get(url, { timeout: 10000 });
|
|
98
|
+
if (status >= 200 && status < 500) {
|
|
99
|
+
checks.push({ name, passed: true, detail: `${host} is reachable` });
|
|
100
|
+
} else {
|
|
101
|
+
allPassed = false;
|
|
102
|
+
checks.push({ name, passed: false, detail: `${host} returned HTTP ${status}`, remediation: `Ensure outbound HTTPS (port 443) to ${host} is allowed. Check DNS resolution and firewall rules.` });
|
|
103
|
+
}
|
|
104
|
+
} catch (err) {
|
|
105
|
+
allPassed = false;
|
|
106
|
+
checks.push({ name, passed: false, detail: `Cannot reach ${host}: ${err.message}`, remediation: `Ensure outbound HTTPS (port 443) to ${host} is allowed. Check DNS resolution and firewall rules.` });
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const result = {
|
|
111
|
+
success: allPassed,
|
|
112
|
+
checks,
|
|
113
|
+
summary: allPassed
|
|
114
|
+
? 'All prerequisites met. Ready to proceed with Relayer setup.'
|
|
115
|
+
: `${checks.filter((c) => !c.passed).length} prerequisite(s) failed. Review the checks above for details and remediation steps.`,
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
120
|
+
};
|
|
121
|
+
},
|
|
122
|
+
);
|
|
123
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
5
|
+
const { pollUntil } = require('../lib/pollUntil');
|
|
6
|
+
|
|
7
|
+
const POLL_INTERVAL_MS = 10000; // 10s
|
|
8
|
+
const POLL_TIMEOUT_MS = 300000; // 300s (AC-13, QA-1)
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Tool 5: check_relayer_health
|
|
12
|
+
* AC-13: report healthy only when ui+s3+hostio healthy; name unhealthy component; 300s timeout.
|
|
13
|
+
* TP-31: hostio = null (not false) before auth — hostio health requires an authenticated
|
|
14
|
+
* proxy call, so before OIDC sign-in we report hostio as null (unknown), not false (down).
|
|
15
|
+
*/
|
|
16
|
+
module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
17
|
+
const http = options.httpClient || createHttpClient(options);
|
|
18
|
+
const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
|
|
19
|
+
const pollTimeout = options.pollTimeoutMs ?? POLL_TIMEOUT_MS;
|
|
20
|
+
const sleep = options.sleep;
|
|
21
|
+
|
|
22
|
+
server.tool(
|
|
23
|
+
'check_relayer_health',
|
|
24
|
+
'Check the health of all Relayer services: UI (port 8888), S3 gateway (port 9000), and HostIO. Polls every 10 seconds for up to 300 seconds. Reports each component status individually and names any unhealthy component. Note: HostIO health status is unknown until OIDC authentication is completed.',
|
|
25
|
+
{
|
|
26
|
+
poll: z.boolean().optional().default(true).describe('If true (default), poll until healthy or timeout. If false, check once.'),
|
|
27
|
+
},
|
|
28
|
+
async ({ poll }) => {
|
|
29
|
+
try {
|
|
30
|
+
const checkOnce = async () => {
|
|
31
|
+
const components = {};
|
|
32
|
+
|
|
33
|
+
// UI health (port 8888)
|
|
34
|
+
try {
|
|
35
|
+
const { status } = await http.get('http://localhost:8888/health', { timeout: 5000 });
|
|
36
|
+
components.ui = { healthy: status >= 200 && status < 500, status };
|
|
37
|
+
} catch {
|
|
38
|
+
components.ui = { healthy: false, status: null };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// S3 gateway (port 9000)
|
|
42
|
+
try {
|
|
43
|
+
const { status } = await http.get('http://localhost:9000/', { timeout: 5000 });
|
|
44
|
+
components.s3 = { healthy: status >= 200 && status < 500, status };
|
|
45
|
+
} catch {
|
|
46
|
+
components.s3 = { healthy: false, status: null };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// HostIO — requires auth. Report null (unknown) not false (down).
|
|
50
|
+
// TP-31: hostio = null before auth
|
|
51
|
+
try {
|
|
52
|
+
const { status } = await http.get('http://localhost:8888/api/v1/proxy/hostio/health', { timeout: 5000 });
|
|
53
|
+
if (status === 401) {
|
|
54
|
+
// Auth required — hostio status is unknown (not unhealthy)
|
|
55
|
+
components.hostio = { healthy: null, status: 401, note: 'Authentication required — HostIO health unknown until OIDC sign-in' };
|
|
56
|
+
} else {
|
|
57
|
+
components.hostio = { healthy: status >= 200 && status < 400, status };
|
|
58
|
+
}
|
|
59
|
+
} catch {
|
|
60
|
+
components.hostio = { healthy: null, status: null, note: 'HostIO health unknown — authentication not yet completed' };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Healthy if UI and S3 are up. HostIO null (unknown) does NOT block.
|
|
64
|
+
const coreHealthy = components.ui.healthy === true && components.s3.healthy === true;
|
|
65
|
+
const allKnownHealthy = coreHealthy && components.hostio.healthy === true;
|
|
66
|
+
|
|
67
|
+
const unhealthy = [];
|
|
68
|
+
if (!components.ui.healthy) unhealthy.push('UI (port 8888)');
|
|
69
|
+
if (!components.s3.healthy) unhealthy.push('S3 gateway (port 9000)');
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
components,
|
|
73
|
+
healthy: coreHealthy,
|
|
74
|
+
all_healthy: allKnownHealthy,
|
|
75
|
+
unhealthy: unhealthy.length > 0 ? unhealthy : undefined,
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!poll) {
|
|
80
|
+
const result = await checkOnce();
|
|
81
|
+
const message = result.healthy
|
|
82
|
+
? 'Relayer core services (UI + S3) are healthy.' + (result.all_healthy ? ' HostIO is also healthy.' : ' HostIO status is pending authentication.')
|
|
83
|
+
: `Relayer is not yet healthy. Unhealthy: ${result.unhealthy.join(', ')}.`;
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: JSON.stringify({ success: result.healthy, message, ...result }, null, 2),
|
|
89
|
+
}],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Poll mode
|
|
94
|
+
const { result, timedOut } = await pollUntil({
|
|
95
|
+
fn: async () => {
|
|
96
|
+
const check = await checkOnce();
|
|
97
|
+
if (check.healthy) return check;
|
|
98
|
+
return null;
|
|
99
|
+
},
|
|
100
|
+
intervalMs: pollInterval,
|
|
101
|
+
timeoutMs: pollTimeout,
|
|
102
|
+
sleep,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
if (timedOut) {
|
|
106
|
+
const finalCheck = await checkOnce();
|
|
107
|
+
const unhealthyNames = finalCheck.unhealthy || [];
|
|
108
|
+
return {
|
|
109
|
+
content: [{
|
|
110
|
+
type: 'text',
|
|
111
|
+
text: JSON.stringify({
|
|
112
|
+
success: false,
|
|
113
|
+
message: `Health check timed out after 300 seconds. Unhealthy components: ${unhealthyNames.join(', ') || 'none identified'}. Check Docker container logs for details.`,
|
|
114
|
+
...finalCheck,
|
|
115
|
+
}, null, 2),
|
|
116
|
+
}],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const message = result.all_healthy
|
|
121
|
+
? 'All Relayer services are healthy (UI, S3, HostIO). Ready for claim.'
|
|
122
|
+
: 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.';
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
content: [{
|
|
126
|
+
type: 'text',
|
|
127
|
+
text: JSON.stringify({ success: true, message, ...result }, null, 2),
|
|
128
|
+
}],
|
|
129
|
+
};
|
|
130
|
+
} catch (err) {
|
|
131
|
+
return {
|
|
132
|
+
content: [{
|
|
133
|
+
type: 'text',
|
|
134
|
+
text: JSON.stringify({
|
|
135
|
+
success: false,
|
|
136
|
+
error: `Health check failed: ${err.message}`,
|
|
137
|
+
}),
|
|
138
|
+
}],
|
|
139
|
+
isError: true,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
};
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { z } = require('zod');
|
|
4
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
5
|
+
const { createTokenManager } = require('../lib/ensureToken');
|
|
6
|
+
|
|
7
|
+
const RELAYER_UI_BASE = 'http://localhost:8888';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Tool 9: configure_vpd
|
|
11
|
+
* AC-18: too-few-hosts 400 → plain re-ask naming 10/20 threshold.
|
|
12
|
+
* AC-19: "use defaults" → configure_vpd("true","true") no further ask.
|
|
13
|
+
* Watch item 1: trigger OIDC re-auth on 401 — never silent failure.
|
|
14
|
+
*
|
|
15
|
+
* Consumes relayer-ui: POST /api/v1/proxy/hostio/v1/hostio/setexpressions
|
|
16
|
+
* via keycloaktoken header (OIDC-authenticated proxy).
|
|
17
|
+
*/
|
|
18
|
+
module.exports = function registerConfigureVpd(server, options = {}) {
|
|
19
|
+
const http = options.httpClient || createHttpClient(options);
|
|
20
|
+
const relayerUiBase = options.relayerUiBase || RELAYER_UI_BASE;
|
|
21
|
+
|
|
22
|
+
const tokenMgr = createTokenManager(options);
|
|
23
|
+
const { ensureToken } = tokenMgr;
|
|
24
|
+
|
|
25
|
+
server.tool(
|
|
26
|
+
'configure_vpd',
|
|
27
|
+
'Configure VPD (Virtual Private Datacenter) host selection for the Relayer. You can either use defaults (recommended for most users) or provide a CEL expression to filter specific hosts by tags. The Relayer requires a minimum of 10 data hosts and 20 parity hosts. If too few hosts match your filter, broaden your criteria. Requires OIDC sign-in (same session as get_host_tags).',
|
|
28
|
+
{
|
|
29
|
+
data_expression: z.string().describe('CEL expression for data host selection. Use "true" for default (all hosts).'),
|
|
30
|
+
parity_expression: z.string().describe('CEL expression for parity host selection. Use "true" for default (all hosts).'),
|
|
31
|
+
},
|
|
32
|
+
async ({ data_expression, parity_expression }) => {
|
|
33
|
+
try {
|
|
34
|
+
let token = await ensureToken();
|
|
35
|
+
|
|
36
|
+
const payload = {
|
|
37
|
+
data_expression,
|
|
38
|
+
parity_expression,
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
let { status, data } = await http.post(
|
|
42
|
+
`${relayerUiBase}/api/v1/proxy/hostio/v1/hostio/setexpressions`,
|
|
43
|
+
payload,
|
|
44
|
+
{ headers: { keycloaktoken: token } },
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// Watch item 1: 401 → re-auth
|
|
48
|
+
if (status === 401) {
|
|
49
|
+
token = await tokenMgr.reauth();
|
|
50
|
+
const retry = await http.post(
|
|
51
|
+
`${relayerUiBase}/api/v1/proxy/hostio/v1/hostio/setexpressions`,
|
|
52
|
+
payload,
|
|
53
|
+
{ headers: { keycloaktoken: token } },
|
|
54
|
+
);
|
|
55
|
+
status = retry.status;
|
|
56
|
+
data = retry.data;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (status === 401) {
|
|
60
|
+
return {
|
|
61
|
+
content: [{
|
|
62
|
+
type: 'text',
|
|
63
|
+
text: JSON.stringify({
|
|
64
|
+
success: false,
|
|
65
|
+
error: 'Authentication failed after re-auth attempt. The OIDC token was rejected by the Relayer proxy.',
|
|
66
|
+
}),
|
|
67
|
+
}],
|
|
68
|
+
isError: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// AC-18: 400 with too-few-hosts → plain re-ask naming 10/20 threshold
|
|
73
|
+
if (status === 400) {
|
|
74
|
+
const msg = typeof data === 'string' ? data : (data.message || data.error || JSON.stringify(data));
|
|
75
|
+
const isTooFew = /too.few|not enough|insufficient/i.test(msg);
|
|
76
|
+
|
|
77
|
+
if (isTooFew) {
|
|
78
|
+
return {
|
|
79
|
+
content: [{
|
|
80
|
+
type: 'text',
|
|
81
|
+
text: JSON.stringify({
|
|
82
|
+
success: false,
|
|
83
|
+
message: `Not enough hosts match your filter. The Relayer requires at least 10 data hosts and 20 parity hosts. Your current expression matched too few hosts. Try broadening your criteria or use defaults ("true" for both expressions).`,
|
|
84
|
+
too_few_hosts: true,
|
|
85
|
+
min_data_hosts: 10,
|
|
86
|
+
min_parity_hosts: 20,
|
|
87
|
+
}, null, 2),
|
|
88
|
+
}],
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Other 400 — likely invalid CEL expression
|
|
93
|
+
return {
|
|
94
|
+
content: [{
|
|
95
|
+
type: 'text',
|
|
96
|
+
text: JSON.stringify({
|
|
97
|
+
success: false,
|
|
98
|
+
error: `VPD configuration rejected: ${msg}. Check that the expression syntax is valid.`,
|
|
99
|
+
}),
|
|
100
|
+
}],
|
|
101
|
+
isError: true,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (status >= 400) {
|
|
106
|
+
return {
|
|
107
|
+
content: [{
|
|
108
|
+
type: 'text',
|
|
109
|
+
text: JSON.stringify({
|
|
110
|
+
success: false,
|
|
111
|
+
error: `VPD configuration failed (HTTP ${status}): ${JSON.stringify(data)}`,
|
|
112
|
+
}),
|
|
113
|
+
}],
|
|
114
|
+
isError: true,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
content: [{
|
|
120
|
+
type: 'text',
|
|
121
|
+
text: JSON.stringify({
|
|
122
|
+
success: true,
|
|
123
|
+
message: 'VPD host selection configured successfully. The Relayer will use the specified host preferences for data storage.',
|
|
124
|
+
data_expression,
|
|
125
|
+
parity_expression,
|
|
126
|
+
}, null, 2),
|
|
127
|
+
}],
|
|
128
|
+
};
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return {
|
|
131
|
+
content: [{
|
|
132
|
+
type: 'text',
|
|
133
|
+
text: JSON.stringify({
|
|
134
|
+
success: false,
|
|
135
|
+
error: `VPD configuration failed: ${err.message}`,
|
|
136
|
+
}),
|
|
137
|
+
}],
|
|
138
|
+
isError: true,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
return { ensureToken, getTokenState: tokenMgr.getTokenState, setTokenState: tokenMgr.setTokenState };
|
|
145
|
+
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { createHttpClient } = require('../lib/httpClient');
|
|
4
|
+
const { createTokenManager } = require('../lib/ensureToken');
|
|
5
|
+
|
|
6
|
+
const RELAYER_UI_BASE = 'http://localhost:8888';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Tool 8: get_host_tags
|
|
10
|
+
* AC-17: translate plain English → CEL; operator never sees "expression".
|
|
11
|
+
* Watch item 1: trigger OIDC re-auth on 401 — never silent failure.
|
|
12
|
+
*
|
|
13
|
+
* Consumes relayer-ui: GET /api/v1/proxy/hostio/v1/hostio/gettags
|
|
14
|
+
* via keycloaktoken header (OIDC-authenticated proxy).
|
|
15
|
+
*/
|
|
16
|
+
module.exports = function registerGetHostTags(server, options = {}) {
|
|
17
|
+
const http = options.httpClient || createHttpClient(options);
|
|
18
|
+
const relayerUiBase = options.relayerUiBase || RELAYER_UI_BASE;
|
|
19
|
+
|
|
20
|
+
const tokenMgr = createTokenManager(options);
|
|
21
|
+
const { ensureToken } = tokenMgr;
|
|
22
|
+
|
|
23
|
+
server.tool(
|
|
24
|
+
'get_host_tags',
|
|
25
|
+
'Get the list of available host tags for VPD (Virtual Private Datacenter) configuration. Returns the raw tags that can be used to build host selection preferences. The agent should translate these tags into plain-language options for the operator — never show raw CEL expressions. Requires OIDC sign-in on first use (the user will be prompted to sign in via browser).',
|
|
26
|
+
{
|
|
27
|
+
/* no parameters */
|
|
28
|
+
},
|
|
29
|
+
async () => {
|
|
30
|
+
try {
|
|
31
|
+
let token = await ensureToken();
|
|
32
|
+
|
|
33
|
+
let { status, data } = await http.get(
|
|
34
|
+
`${relayerUiBase}/api/v1/proxy/hostio/v1/hostio/gettags`,
|
|
35
|
+
{ headers: { keycloaktoken: token } },
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
// Watch item 1: 401 → re-auth, never silent failure
|
|
39
|
+
if (status === 401) {
|
|
40
|
+
token = await tokenMgr.reauth();
|
|
41
|
+
const retry = await http.get(
|
|
42
|
+
`${relayerUiBase}/api/v1/proxy/hostio/v1/hostio/gettags`,
|
|
43
|
+
{ headers: { keycloaktoken: token } },
|
|
44
|
+
);
|
|
45
|
+
status = retry.status;
|
|
46
|
+
data = retry.data;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (status === 401) {
|
|
50
|
+
return {
|
|
51
|
+
content: [{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: JSON.stringify({
|
|
54
|
+
success: false,
|
|
55
|
+
error: 'Authentication failed after re-auth attempt. The OIDC token was rejected by the Relayer proxy. Check Keycloak client configuration.',
|
|
56
|
+
}),
|
|
57
|
+
}],
|
|
58
|
+
isError: true,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (status >= 400) {
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: 'text',
|
|
66
|
+
text: JSON.stringify({
|
|
67
|
+
success: false,
|
|
68
|
+
error: `Failed to retrieve host tags (HTTP ${status}): ${JSON.stringify(data)}`,
|
|
69
|
+
}),
|
|
70
|
+
}],
|
|
71
|
+
isError: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const tags = data.tags || data || [];
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [{
|
|
79
|
+
type: 'text',
|
|
80
|
+
text: JSON.stringify({
|
|
81
|
+
success: true,
|
|
82
|
+
tags,
|
|
83
|
+
message: 'Host tags retrieved. Present these as plain-language options to the operator (e.g., "US-based hosts", "European hosts"). Never show raw tag names or CEL expressions directly. Use configure_vpd to apply the selection.',
|
|
84
|
+
}, null, 2),
|
|
85
|
+
}],
|
|
86
|
+
};
|
|
87
|
+
} catch (err) {
|
|
88
|
+
return {
|
|
89
|
+
content: [{
|
|
90
|
+
type: 'text',
|
|
91
|
+
text: JSON.stringify({
|
|
92
|
+
success: false,
|
|
93
|
+
error: `Failed to get host tags: ${err.message}`,
|
|
94
|
+
}),
|
|
95
|
+
}],
|
|
96
|
+
isError: true,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Expose for testing
|
|
103
|
+
return { ensureToken, getTokenState: tokenMgr.getTokenState, setTokenState: tokenMgr.setTokenState };
|
|
104
|
+
};
|