@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.
@@ -0,0 +1,102 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+ const path = require('path');
5
+ const { createDockerUtil } = require('../lib/dockerUtil');
6
+
7
+ // Bundled released-install template (ships in the npm package; package.json
8
+ // `files: ["src/"]` covers it). This is the documented xns.tech install.
9
+ const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
10
+
11
+ /**
12
+ * Tool 4: install_relayer
13
+ * AC-12: confirms "containers starting"; no manual shell.
14
+ * TP-30: uses execFile, no shell (security non-negotiable).
15
+ *
16
+ * A first-time user has never heard of a Relayer and cannot supply a compose
17
+ * file or a .env. So by default this tool WRITES both for them — the bundled
18
+ * released compose (Docker Hub scprime/xns-relayer, pre-release :beta channel,
19
+ * per https://xns.tech/docs/windows-ui/) plus a .env carrying the two ports.
20
+ *
21
+ * `compose_url` stays as an optional override for internal/custom installs;
22
+ * when given, the old download-a-URL behaviour is preserved.
23
+ */
24
+ module.exports = function registerInstallRelayer(server, options = {}) {
25
+ const docker = options.dockerUtil || createDockerUtil(options);
26
+ const _execFile = options.execFile;
27
+ const fsp = options.fs || require('fs').promises;
28
+
29
+ server.tool(
30
+ 'install_relayer',
31
+ 'Install and start the XNS Relayer. By default writes the bundled docker-compose.yml (Docker Hub scprime/xns-relayer, pre-release :beta channel) and a .env, then runs docker compose up -d — the user does NOT need to author any file. Pass compose_url only to override with a custom compose.',
32
+ {
33
+ install_path: z.string().optional().default('/opt/xns-relayer').describe('Directory to install the compose file into'),
34
+ ui_port: z.number().int().positive().optional().default(8888).describe('Host port for the Relayer admin/customer UI (container 8888)'),
35
+ minio_port: z.number().int().positive().optional().default(9000).describe('Host port for the S3 API (container 9000)'),
36
+ compose_url: z.string().url().optional().describe('OPTIONAL override: URL to a custom docker-compose.yml. Omit for the normal released install.'),
37
+ },
38
+ async ({ install_path, ui_port, minio_port, compose_url }) => {
39
+ try {
40
+ const { execFile: nodeExecFile } = require('child_process');
41
+ const execFileFn = _execFile || nodeExecFile;
42
+ const composePath = path.join(install_path, 'docker-compose.yml');
43
+ const envPath = path.join(install_path, '.env');
44
+
45
+ // Create install directory (execFile, no shell)
46
+ await new Promise((resolve, reject) => {
47
+ execFileFn('mkdir', ['-p', install_path], {}, (err) => {
48
+ if (err) return reject(new Error(`Failed to create directory ${install_path}: ${err.message}`));
49
+ resolve();
50
+ });
51
+ });
52
+
53
+ if (compose_url) {
54
+ // Override path: download a custom compose (execFile, no shell).
55
+ await new Promise((resolve, reject) => {
56
+ execFileFn('curl', ['-fsSL', '-o', composePath, compose_url], { timeout: 60000 }, (err) => {
57
+ if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
58
+ resolve();
59
+ });
60
+ });
61
+ } else {
62
+ // Default path: write the bundled released compose + .env for the user.
63
+ const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
64
+ await fsp.writeFile(composePath, template);
65
+ await fsp.writeFile(envPath, `UI_PORT=${ui_port}\nMINIO_PORT=${minio_port}\n`);
66
+ }
67
+
68
+ // Run docker compose up -d. cwd + env so ${UI_PORT}/${MINIO_PORT}
69
+ // interpolation and the ./data bind resolve in the install dir,
70
+ // regardless of where the MCP process was launched.
71
+ await docker.composeUp(composePath, {
72
+ cwd: install_path,
73
+ env: { ...process.env, UI_PORT: String(ui_port), MINIO_PORT: String(minio_port) },
74
+ });
75
+
76
+ return {
77
+ content: [{
78
+ type: 'text',
79
+ text: JSON.stringify({
80
+ success: true,
81
+ message: 'XNS Relayer containers are starting. Use check_relayer_health to monitor when all services are ready.',
82
+ compose_path: composePath,
83
+ install_path,
84
+ source: compose_url ? 'compose_url' : 'bundled',
85
+ }, null, 2),
86
+ }],
87
+ };
88
+ } catch (err) {
89
+ return {
90
+ content: [{
91
+ type: 'text',
92
+ text: JSON.stringify({
93
+ success: false,
94
+ error: `Relayer installation failed: ${err.message}`,
95
+ }),
96
+ }],
97
+ isError: true,
98
+ };
99
+ }
100
+ },
101
+ );
102
+ };
@@ -0,0 +1,123 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+ const { createHttpClient } = require('../lib/httpClient');
5
+
6
+ const CONSOLE_BASE_URL = 'https://console.xns.tech';
7
+
8
+ /**
9
+ * Tool 2: register_account
10
+ * AC-5: silent register, confirms account at {email}.
11
+ * AC-6: 409 → "email already has account" + next_action skip hint.
12
+ * AC-7: 422 → names the specific password rule.
13
+ * AC-8: Gate 1 dialogue names email + auth.xns.tech + auto-check.
14
+ *
15
+ * Consumes E2: POST /api/auth/register
16
+ * → 201 {success:true, userId, message:"Verification email sent"}
17
+ * → 409 {success:false, message} (already registered)
18
+ * → 422 {success:false, message} (validation error — password rules)
19
+ * → 429 {success:false, message} (rate limited)
20
+ */
21
+ module.exports = function registerRegisterAccount(server, options = {}) {
22
+ const http = options.httpClient || createHttpClient(options);
23
+ const consoleBaseUrl = options.consoleBaseUrl || CONSOLE_BASE_URL;
24
+
25
+ server.tool(
26
+ 'register_account',
27
+ 'Register a new XNS account with an email and password. After registration, the user must verify their email at auth.xns.tech by clicking the link sent to their inbox. Use check_email_verified to poll for verification status. If the email already has an account, you can skip to install_relayer.',
28
+ {
29
+ email: z.string().email().describe('User email address'),
30
+ password: z.string().min(8).describe('Password (minimum 8 characters)'),
31
+ },
32
+ async ({ email, password }) => {
33
+ try {
34
+ const { status, data } = await http.post(
35
+ `${consoleBaseUrl}/api/auth/register`,
36
+ { email, password },
37
+ );
38
+
39
+ if (status === 201) {
40
+ return {
41
+ content: [{
42
+ type: 'text',
43
+ text: JSON.stringify({
44
+ success: true,
45
+ message: `Account registered for ${email}. A verification email has been sent. The user must click the verification link at auth.xns.tech before proceeding. Use check_email_verified to poll for verification.`,
46
+ email,
47
+ userId: data.userId,
48
+ }),
49
+ }],
50
+ };
51
+ }
52
+
53
+ if (status === 409) {
54
+ // AC-6: carry next_action skip hint
55
+ return {
56
+ content: [{
57
+ type: 'text',
58
+ text: JSON.stringify({
59
+ success: false,
60
+ message: `Email ${email} already has an account. You can skip registration and proceed directly to install_relayer.`,
61
+ next_action: 'skip_to_install',
62
+ email,
63
+ }),
64
+ }],
65
+ };
66
+ }
67
+
68
+ if (status === 422) {
69
+ // AC-7: name the specific password rule
70
+ return {
71
+ content: [{
72
+ type: 'text',
73
+ text: JSON.stringify({
74
+ success: false,
75
+ message: data.message || 'Validation error — check password requirements (minimum 8 characters, at least one uppercase, one lowercase, one number).',
76
+ email,
77
+ }),
78
+ }],
79
+ isError: true,
80
+ };
81
+ }
82
+
83
+ if (status === 429) {
84
+ return {
85
+ content: [{
86
+ type: 'text',
87
+ text: JSON.stringify({
88
+ success: false,
89
+ message: data.message || 'Rate limited. Please wait before retrying registration.',
90
+ email,
91
+ }),
92
+ }],
93
+ isError: true,
94
+ };
95
+ }
96
+
97
+ // Unexpected status
98
+ return {
99
+ content: [{
100
+ type: 'text',
101
+ text: JSON.stringify({
102
+ success: false,
103
+ error: `Unexpected response (HTTP ${status}): ${data.message || JSON.stringify(data)}`,
104
+ email,
105
+ }),
106
+ }],
107
+ isError: true,
108
+ };
109
+ } catch (err) {
110
+ return {
111
+ content: [{
112
+ type: 'text',
113
+ text: JSON.stringify({
114
+ success: false,
115
+ error: `Registration request failed: ${err.message}`,
116
+ }),
117
+ }],
118
+ isError: true,
119
+ };
120
+ }
121
+ },
122
+ );
123
+ };
@@ -0,0 +1,117 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const os = require('os');
6
+ const { z } = require('zod');
7
+ const { createHttpClient } = require('../lib/httpClient');
8
+
9
+ /**
10
+ * Tool 11: setup_cli_credentials
11
+ *
12
+ * After check_claim_status reaches STATE_3, the user has a provisioned Relayer
13
+ * but no CLI credentials. This tool:
14
+ * 1. Creates an IAM user "xns-cli" via the Relayer UI MC proxy
15
+ * 2. Receives the one-time {access_key, secret_key} response
16
+ * 3. Writes ~/.xns/credentials (AC-11 JSON schema, mode 0600)
17
+ *
18
+ * The muse_token is the Keycloak JWT already held by the agent from the
19
+ * configure_vpd / get_host_tags calls — no new auth step required.
20
+ */
21
+ module.exports = function registerSetupCliCredentials(server, options = {}) {
22
+ const http = options.httpClient || createHttpClient(options);
23
+
24
+ server.tool(
25
+ 'setup_cli_credentials',
26
+ 'Provision S3 IAM credentials for the XNS CLI. Creates an IAM user in the Relayer and writes ~/.xns/credentials so that `xns ls` and other S3 verbs work without further configuration. Call once after check_claim_status reaches STATE_3.',
27
+ {
28
+ muse_token: z.string().describe('Keycloak/Muse token — the same token used for get_host_tags and configure_vpd'),
29
+ installation_id: z.string().optional().default('').describe('Installation ID from check_claim_status STATE_3 result — used as cost_center_id in credentials'),
30
+ relayer_ui_url: z.string().optional().default('http://localhost:8888').describe('Relayer UI base URL (default: http://localhost:8888)'),
31
+ },
32
+ async ({ muse_token, installation_id, relayer_ui_url }) => {
33
+ try {
34
+ // Step 1: create IAM user "xns-cli" via MC proxy
35
+ const { status, data } = await http.post(
36
+ `${relayer_ui_url}/api/v1/mc/user`,
37
+ { user: 'xns-cli' },
38
+ { headers: { keycloaktoken: muse_token } }
39
+ );
40
+
41
+ if (status !== 200) {
42
+ return {
43
+ content: [{
44
+ type: 'text',
45
+ text: JSON.stringify({
46
+ success: false,
47
+ error: `Relayer returned HTTP ${status}`,
48
+ detail: data,
49
+ }),
50
+ }],
51
+ };
52
+ }
53
+
54
+ const ak = data.access_key || data.AccessKey || '';
55
+ const sk = data.secret_key || data.SecretKey || '';
56
+
57
+ if (!ak || !sk) {
58
+ const msg = data.message || data.Message || data.error || data.Error || 'no credentials in response';
59
+ return {
60
+ content: [{
61
+ type: 'text',
62
+ text: JSON.stringify({
63
+ success: false,
64
+ error: `Relayer: ${msg}`,
65
+ hint: 'CLI credentials may already exist. Run `xns ls` — if it works, credentials are valid.',
66
+ }),
67
+ }],
68
+ };
69
+ }
70
+
71
+ // Step 2: derive S3 endpoint (same host, port 9000)
72
+ const s3Endpoint = relayer_ui_url.replace(/:(\d+)(\/|$)/, ':9000$2');
73
+
74
+ // Step 3: write ~/.xns/credentials (AC-11 JSON schema, mode 0600)
75
+ const credsPath = path.join(os.homedir(), '.xns', 'credentials');
76
+ fs.mkdirSync(path.dirname(credsPath), { recursive: true });
77
+
78
+ const creds = {
79
+ version: 1,
80
+ profiles: {
81
+ default: {
82
+ endpoint: s3Endpoint,
83
+ access_key_id: ak,
84
+ secret_access_key: sk,
85
+ cost_center_id: installation_id,
86
+ muse_token: muse_token,
87
+ },
88
+ },
89
+ active_profile: 'default',
90
+ };
91
+
92
+ fs.writeFileSync(credsPath, JSON.stringify(creds, null, 2), { mode: 0o600 });
93
+
94
+ return {
95
+ content: [{
96
+ type: 'text',
97
+ text: JSON.stringify({
98
+ success: true,
99
+ message: 'CLI credentials written to ~/.xns/credentials. The user can now run `xns ls` to list their storage.',
100
+ s3_endpoint: s3Endpoint,
101
+ }),
102
+ }],
103
+ };
104
+ } catch (err) {
105
+ return {
106
+ content: [{
107
+ type: 'text',
108
+ text: JSON.stringify({
109
+ success: false,
110
+ error: err.message,
111
+ }),
112
+ }],
113
+ };
114
+ }
115
+ }
116
+ );
117
+ };
@@ -0,0 +1,99 @@
1
+ 'use strict';
2
+
3
+ const { createHttpClient } = require('../lib/httpClient');
4
+
5
+ const RELAYER_UI_BASE = 'http://localhost:8888';
6
+
7
+ /**
8
+ * Tool 6: start_claim
9
+ * AC-14: Gate 2 dialogue: claim_url + expires_at + auto-detect.
10
+ * AC-15a: on 402: stop polling, do NOT loop TTL, name specific action.
11
+ *
12
+ * Consumes relayer-ui: POST /api/v1/claim/init
13
+ * → {claim_id, claim_url, expires_at}
14
+ * → 402: payment method required (E3 not yet deployed)
15
+ * → 502: upstream error
16
+ */
17
+ module.exports = function registerStartClaim(server, options = {}) {
18
+ const http = options.httpClient || createHttpClient(options);
19
+ const relayerUiBase = options.relayerUiBase || RELAYER_UI_BASE;
20
+
21
+ server.tool(
22
+ 'start_claim',
23
+ 'Start a claim session to link this Relayer installation to an XNS account. Returns a claim URL that the user must open in a browser to complete the claim. The claim has an expiration time. After calling this, use check_claim_status to monitor claim progress.',
24
+ {
25
+ /* no parameters */
26
+ },
27
+ async () => {
28
+ try {
29
+ const { status, data } = await http.post(`${relayerUiBase}/api/v1/claim/init`, {});
30
+
31
+ // AC-15a: 402 → stop, do NOT loop, name the action
32
+ if (status === 402) {
33
+ return {
34
+ content: [{
35
+ type: 'text',
36
+ text: JSON.stringify({
37
+ success: false,
38
+ continue_polling: false,
39
+ message: 'A payment method is required before claiming a Relayer. Please add a payment method to your account at console.xns.tech, then run start_claim again.',
40
+ }, null, 2),
41
+ }],
42
+ };
43
+ }
44
+
45
+ if (status === 502 || status >= 500) {
46
+ return {
47
+ content: [{
48
+ type: 'text',
49
+ text: JSON.stringify({
50
+ success: false,
51
+ error: data.error || 'Failed to initiate claim — upstream service error. Try again in a few seconds.',
52
+ }),
53
+ }],
54
+ isError: true,
55
+ };
56
+ }
57
+
58
+ if (status >= 400) {
59
+ return {
60
+ content: [{
61
+ type: 'text',
62
+ text: JSON.stringify({
63
+ success: false,
64
+ error: data.error || `Claim initiation failed (HTTP ${status}).`,
65
+ }),
66
+ }],
67
+ isError: true,
68
+ };
69
+ }
70
+
71
+ // Success — AC-14: present claim_url + expires_at
72
+ return {
73
+ content: [{
74
+ type: 'text',
75
+ text: JSON.stringify({
76
+ success: true,
77
+ message: `Claim session started. The user must open the following URL in a browser to complete the claim. This session expires at ${data.expires_at}.`,
78
+ claim_id: data.claim_id,
79
+ claim_url: data.claim_url,
80
+ expires_at: data.expires_at,
81
+ next_action: 'Use check_claim_status to monitor when the claim is completed.',
82
+ }, null, 2),
83
+ }],
84
+ };
85
+ } catch (err) {
86
+ return {
87
+ content: [{
88
+ type: 'text',
89
+ text: JSON.stringify({
90
+ success: false,
91
+ error: `Claim initiation failed: ${err.message}`,
92
+ }),
93
+ }],
94
+ isError: true,
95
+ };
96
+ }
97
+ },
98
+ );
99
+ };
@@ -0,0 +1,95 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+ const { createS3Client } = require('../lib/s3Client');
5
+
6
+ /**
7
+ * Tool 10: verify_storage
8
+ * AC-20: targets localhost:9000 plain HTTP; reports endpoint.
9
+ * AC-21: verify fail → names the failing step (CreateBucket/PutObject/GetObject).
10
+ * R6: S3 credentials must be provided (from relayer-ui IAM key-mgmt flow).
11
+ *
12
+ * Performs a round-trip verification: CreateBucket → PutObject → GetObject → compare.
13
+ */
14
+ module.exports = function registerVerifyStorage(server, options = {}) {
15
+ const _createS3Client = options.createS3Client || createS3Client;
16
+
17
+ server.tool(
18
+ 'verify_storage',
19
+ 'Verify the S3-compatible storage gateway is working by performing a round-trip test: create a test bucket, upload a small object, download it, and compare. Targets http://localhost:9000 (the local S3 gateway). You must provide S3 access credentials — these can be found in the Relayer configuration or generated via the Relayer UI.',
20
+ {
21
+ access_key_id: z.string().describe('S3 access key ID'),
22
+ secret_access_key: z.string().describe('S3 secret access key'),
23
+ endpoint: z.string().optional().default('http://localhost:9000').describe('S3 endpoint URL (default: http://localhost:9000)'),
24
+ },
25
+ async ({ access_key_id, secret_access_key, endpoint }) => {
26
+ const testBucket = `mcp-verify-${Date.now()}`;
27
+ const testKey = 'verify-test.txt';
28
+ const testContent = `relayer-mcp verification ${Date.now()}`;
29
+ let currentStep = 'init';
30
+
31
+ try {
32
+ const s3 = _createS3Client({
33
+ endpoint,
34
+ accessKeyId: access_key_id,
35
+ secretAccessKey: secret_access_key,
36
+ });
37
+
38
+ // Step 1: CreateBucket
39
+ currentStep = 'CreateBucket';
40
+ await s3.createBucket(testBucket);
41
+
42
+ // Step 2: PutObject
43
+ currentStep = 'PutObject';
44
+ await s3.putObject(testBucket, testKey, testContent);
45
+
46
+ // Step 3: GetObject
47
+ currentStep = 'GetObject';
48
+ const retrieved = await s3.getObject(testBucket, testKey);
49
+
50
+ // Step 4: Compare
51
+ currentStep = 'Compare';
52
+ if (retrieved !== testContent) {
53
+ return {
54
+ content: [{
55
+ type: 'text',
56
+ text: JSON.stringify({
57
+ success: false,
58
+ error: 'Storage verification failed at Compare: uploaded content does not match downloaded content. The S3 gateway may have data integrity issues.',
59
+ endpoint,
60
+ failing_step: 'Compare',
61
+ }),
62
+ }],
63
+ isError: true,
64
+ };
65
+ }
66
+
67
+ return {
68
+ content: [{
69
+ type: 'text',
70
+ text: JSON.stringify({
71
+ success: true,
72
+ message: `S3 storage verification passed. Successfully created bucket, uploaded object, and verified download at ${endpoint}.`,
73
+ endpoint,
74
+ test_bucket: testBucket,
75
+ }, null, 2),
76
+ }],
77
+ };
78
+ } catch (err) {
79
+ // AC-21: name the failing step
80
+ return {
81
+ content: [{
82
+ type: 'text',
83
+ text: JSON.stringify({
84
+ success: false,
85
+ error: `Storage verification failed at ${currentStep}: ${err.message}`,
86
+ endpoint,
87
+ failing_step: currentStep,
88
+ }),
89
+ }],
90
+ isError: true,
91
+ };
92
+ }
93
+ },
94
+ );
95
+ };