@xns-cloud/relayer-mcp 0.4.0 → 0.5.1
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/README.md +3 -3
- package/package.json +4 -2
- package/src/lib/s3Client.js +19 -0
- package/src/templates/docker-compose.yml +57 -13
- package/src/tools/checkRelayerHealth.js +31 -4
- package/src/tools/installRelayer.js +42 -18
- package/src/tools/verifyStorage.js +76 -27
package/README.md
CHANGED
|
@@ -46,13 +46,13 @@ No separate install step required.
|
|
|
46
46
|
| 1 | `check_prerequisites` | Verify Docker (local or remote), ports (8888, 9000), an existing installation, disk, and network connectivity. |
|
|
47
47
|
| 2 | `register_account` | Register an XNS account (email + password) via Console2. |
|
|
48
48
|
| 3 | `check_email_verified` | Poll email verification status (15s interval, 30-min timeout). |
|
|
49
|
-
| 4 | `install_relayer` |
|
|
50
|
-
| 5 | `check_relayer_health` | Poll UI, S3, and
|
|
49
|
+
| 4 | `install_relayer` | Fetch the canonical beta channel bundle — relayer + Prometheus/Grafana monitoring stack (`https://releases.scpri.me/relayer/beta/docker-compose.yml`, anonymous pull, no `docker login`) — write the `.env`, and start the containers. Falls back to a bundled service-parity copy if the fetch fails. **Fresh installs only** — see [Fresh installs vs. existing deployments](#fresh-installs-vs-existing-deployments). The user authors nothing; `compose_url` is an optional override for custom installs. |
|
|
50
|
+
| 5 | `check_relayer_health` | Poll UI, S3, HostIO, and the monitoring sidecars (10s interval, 300s timeout). A missing monitoring stack reports as degraded without blocking the flow. Targets the Docker host automatically. |
|
|
51
51
|
| 6 | `start_claim` | Initiate a claim session — returns a URL for browser confirmation. |
|
|
52
52
|
| 7 | `check_claim_status` | Poll claim state (STATE_1 / STATE_2 / STATE_3). |
|
|
53
53
|
| 8 | `get_host_tags` | Retrieve available host tags for VPD configuration. |
|
|
54
54
|
| 9 | `configure_vpd` | Set data/parity host selection via CEL expressions. |
|
|
55
|
-
| 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) against the S3 gateway on port 9000. |
|
|
55
|
+
| 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) against the S3 gateway on port 9000. **Requires fullaccess credentials** (admin key pair from the Relayer UI IAM page — not a read-only or bucket-scoped key). Test bucket is cleaned up automatically. If auto-detection cannot reach the host, pass `endpoint` with an explicit IP (e.g. `http://192.168.1.100:9000`). |
|
|
56
56
|
| 11 | `setup_cli_credentials` | Provision S3 IAM credentials and write `~/.xns/credentials` so the XNS CLI works without further configuration. |
|
|
57
57
|
|
|
58
58
|
## Onboarding Flow
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xns-cloud/relayer-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.1",
|
|
4
4
|
"description": "MCP server for XNS Relayer onboarding — drives a complete Relayer setup conversationally over stdio, npx-ready",
|
|
5
5
|
"author": "SCP Corp",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -47,7 +47,9 @@
|
|
|
47
47
|
"jest": "^30.4.2"
|
|
48
48
|
},
|
|
49
49
|
"jest": {
|
|
50
|
-
"testMatch": [
|
|
50
|
+
"testMatch": [
|
|
51
|
+
"**/src/__tests__/**/*.test.js"
|
|
52
|
+
],
|
|
51
53
|
"testEnvironment": "node",
|
|
52
54
|
"forceExit": true
|
|
53
55
|
}
|
package/src/lib/s3Client.js
CHANGED
|
@@ -5,6 +5,8 @@ const {
|
|
|
5
5
|
CreateBucketCommand,
|
|
6
6
|
PutObjectCommand,
|
|
7
7
|
GetObjectCommand,
|
|
8
|
+
DeleteObjectCommand,
|
|
9
|
+
DeleteBucketCommand,
|
|
8
10
|
} = require('@aws-sdk/client-s3');
|
|
9
11
|
|
|
10
12
|
/**
|
|
@@ -65,6 +67,23 @@ function createS3Client(options = {}) {
|
|
|
65
67
|
return resp.Body.transformToString();
|
|
66
68
|
},
|
|
67
69
|
|
|
70
|
+
/**
|
|
71
|
+
* @param {string} bucket
|
|
72
|
+
* @param {string} key
|
|
73
|
+
* @returns {Promise<void>}
|
|
74
|
+
*/
|
|
75
|
+
async deleteObject(bucket, key) {
|
|
76
|
+
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: key }));
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @param {string} bucket
|
|
81
|
+
* @returns {Promise<void>}
|
|
82
|
+
*/
|
|
83
|
+
async deleteBucket(bucket) {
|
|
84
|
+
await client.send(new DeleteBucketCommand({ Bucket: bucket }));
|
|
85
|
+
},
|
|
86
|
+
|
|
68
87
|
/** Expose the raw client for advanced use */
|
|
69
88
|
client,
|
|
70
89
|
};
|
|
@@ -1,29 +1,38 @@
|
|
|
1
|
-
# Bundled by @xns-cloud/relayer-mcp — the released
|
|
2
|
-
# Source of truth: the beta
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
1
|
+
# Bundled by @xns-cloud/relayer-mcp — OFFLINE FALLBACK for the released install.
|
|
2
|
+
# Source of truth: the beta channel bundle on the XNS releases registry —
|
|
3
|
+
# https://releases.scpri.me/relayer/beta/docker-compose.yml (versioned in the
|
|
4
|
+
# deploy repo, shipped by `deploy.py promote`). install_relayer fetches that URL
|
|
5
|
+
# by default and only writes this copy when the fetch fails, so this file MUST
|
|
6
|
+
# keep service parity with the channel bundle: relayer + the monitoring stack
|
|
7
|
+
# (Prometheus + Grafana + node-exporter) that powers the dashboards under
|
|
8
|
+
# Monitoring in the web UI. The jest contract tests enforce the parity.
|
|
9
|
+
#
|
|
10
|
+
# NEVER point an image at Docker Hub scprime/* — those are production fleet
|
|
11
|
+
# tags, not a release channel. The one allowed upstream image is
|
|
12
|
+
# prom/node-exporter (public, multi-arch, version-pinned — matches the channel
|
|
13
|
+
# bundle; nothing to rebuild).
|
|
8
14
|
#
|
|
9
15
|
# Pre-release: pinned to :beta-latest so beta testers exercise the current
|
|
10
|
-
# Relayer agentically. Flip
|
|
16
|
+
# Relayer agentically. Flip these tags to :stable-latest at general release.
|
|
11
17
|
#
|
|
12
|
-
# A first-time user
|
|
18
|
+
# A first-time user never hand-writes this file or the .env — install_relayer
|
|
13
19
|
# writes both. Differences from the channel compose, by design:
|
|
14
|
-
# - data lives in ./data (relative to this file) instead of a named
|
|
15
|
-
# so the same file works on Windows, macOS, and Linux and the data
|
|
16
|
-
# to find next to the compose.
|
|
20
|
+
# - relayer data lives in ./data (relative to this file) instead of a named
|
|
21
|
+
# volume, so the same file works on Windows, macOS, and Linux and the data
|
|
22
|
+
# is easy to find next to the compose.
|
|
17
23
|
# - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
|
|
18
24
|
# SMB on consumer hosts and would fail the install. S3 onboarding uses :9000.
|
|
19
25
|
# - pull_policy: always so `docker compose up -d` fetches the current beta
|
|
20
|
-
#
|
|
26
|
+
# images without a separate `docker compose pull` step.
|
|
21
27
|
services:
|
|
22
28
|
xns:
|
|
29
|
+
# container_name is load-bearing: the baked-in prometheus.yml scrapes the
|
|
30
|
+
# relayer by this name, and the install preflight keys on it.
|
|
23
31
|
container_name: xns-relayer
|
|
24
32
|
image: releases.scpri.me/xns-relayer:beta-latest
|
|
25
33
|
pull_policy: always
|
|
26
34
|
restart: unless-stopped
|
|
35
|
+
privileged: true # samba / fuse / disk management (matches channel compose)
|
|
27
36
|
volumes:
|
|
28
37
|
- ./data:/relayer
|
|
29
38
|
ports:
|
|
@@ -31,3 +40,38 @@ services:
|
|
|
31
40
|
- ${S3_PORT:-9000}:9000
|
|
32
41
|
env_file:
|
|
33
42
|
- .env
|
|
43
|
+
|
|
44
|
+
# Monitoring stack — powers the dashboards under Monitoring in the web UI.
|
|
45
|
+
# Not host-exposed: Grafana is reached only through the relayer's /grafana
|
|
46
|
+
# reverse proxy and the in-network Prometheus scrape.
|
|
47
|
+
prometheus:
|
|
48
|
+
image: releases.scpri.me/relayer-prometheus:beta-latest
|
|
49
|
+
pull_policy: always
|
|
50
|
+
restart: unless-stopped
|
|
51
|
+
volumes:
|
|
52
|
+
- prometheus_data:/prometheus
|
|
53
|
+
|
|
54
|
+
grafana:
|
|
55
|
+
image: releases.scpri.me/relayer-grafana:beta-latest
|
|
56
|
+
pull_policy: always
|
|
57
|
+
restart: unless-stopped
|
|
58
|
+
environment:
|
|
59
|
+
- GF_AUTH_ANONYMOUS_ENABLED=true
|
|
60
|
+
- GF_AUTH_ANONYMOUS_ORG_ROLE=Admin
|
|
61
|
+
- GF_SERVER_SERVE_FROM_SUB_PATH=true
|
|
62
|
+
- GF_SERVER_ROOT_URL=%(protocol)s://%(domain)s/grafana
|
|
63
|
+
- GF_SECURITY_ALLOW_EMBEDDING=true
|
|
64
|
+
volumes:
|
|
65
|
+
- grafana_data:/var/lib/grafana
|
|
66
|
+
|
|
67
|
+
node-exporter:
|
|
68
|
+
image: prom/node-exporter:v1.11.1
|
|
69
|
+
restart: unless-stopped
|
|
70
|
+
command:
|
|
71
|
+
- --path.rootfs=/host
|
|
72
|
+
volumes:
|
|
73
|
+
- /:/host:ro # read-only host metrics for the Node Health page
|
|
74
|
+
|
|
75
|
+
volumes:
|
|
76
|
+
prometheus_data:
|
|
77
|
+
grafana_data:
|
|
@@ -53,7 +53,7 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
|
53
53
|
|
|
54
54
|
server.tool(
|
|
55
55
|
'check_relayer_health',
|
|
56
|
-
'Check the health of all Relayer services: UI (port 8888), S3 gateway (port 9000), and
|
|
56
|
+
'Check the health of all Relayer services: UI (port 8888), S3 gateway (port 9000), HostIO, and the monitoring sidecars (Prometheus + Grafana containers). Polls every 10 seconds for up to 300 seconds. Reports each component status individually and names any unhealthy component; a missing monitoring stack reports as degraded (dashboards empty) without blocking the install flow. Targets the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass host to override. Note: HostIO health status is unknown until OIDC authentication is completed.',
|
|
57
57
|
{
|
|
58
58
|
poll: z.boolean().optional().default(true).describe('If true (default), poll until healthy or timeout. If false, check once.'),
|
|
59
59
|
host: z.string().trim().min(1).optional().describe('Hostname/IP where the Relayer containers run. Default: auto-detected from the Docker context (localhost, or the remote host for ssh:// / tcp:// contexts).'),
|
|
@@ -104,6 +104,25 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
|
104
104
|
components.hostio = { healthy: null, status: null, note: 'HostIO health unknown — authentication not yet completed' };
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
// Monitoring sidecars (Prometheus + Grafana) — the channel
|
|
108
|
+
// bundle ships them; without them the dashboards under
|
|
109
|
+
// Monitoring in the web UI are dead. Absence DEGRADES the
|
|
110
|
+
// install (surfaced + named) but never blocks the core flow
|
|
111
|
+
// — claim/onboarding doesn't depend on monitoring.
|
|
112
|
+
const [prometheus, grafana] = await Promise.all([
|
|
113
|
+
docker.isContainerRunning('prometheus'),
|
|
114
|
+
docker.isContainerRunning('grafana'),
|
|
115
|
+
]);
|
|
116
|
+
const monitoringHealthy = prometheus === true && grafana === true;
|
|
117
|
+
components.monitoring = {
|
|
118
|
+
healthy: monitoringHealthy,
|
|
119
|
+
prometheus,
|
|
120
|
+
grafana,
|
|
121
|
+
...(monitoringHealthy ? {} : {
|
|
122
|
+
note: 'Monitoring stack (Prometheus/Grafana) is not running — the dashboards under Monitoring in the web UI will be empty. The channel bundle compose includes both; re-run docker compose up -d with the channel bundle to add them.',
|
|
123
|
+
}),
|
|
124
|
+
};
|
|
125
|
+
|
|
107
126
|
// Healthy if UI and S3 are up. HostIO null (unknown) does NOT block.
|
|
108
127
|
const coreHealthy = components.ui.healthy === true && components.s3.healthy === true;
|
|
109
128
|
const allKnownHealthy = coreHealthy && components.hostio.healthy === true;
|
|
@@ -116,16 +135,24 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
|
116
135
|
components,
|
|
117
136
|
healthy: coreHealthy,
|
|
118
137
|
all_healthy: allKnownHealthy,
|
|
138
|
+
// JSON.stringify drops undefined — degraded only appears when true.
|
|
139
|
+
degraded: monitoringHealthy ? undefined : true,
|
|
119
140
|
unhealthy: unhealthy.length > 0 ? unhealthy : undefined,
|
|
120
141
|
target_host: target,
|
|
121
142
|
remote_docker: dockerHost.remote === true || undefined,
|
|
122
143
|
};
|
|
123
144
|
};
|
|
124
145
|
|
|
146
|
+
// Degraded (monitoring down) is appended to ANY healthy message —
|
|
147
|
+
// success stays true, the gap is named instead of swallowed.
|
|
148
|
+
const degradedSuffix = (r) => (r.degraded
|
|
149
|
+
? ' DEGRADED: monitoring stack (Prometheus/Grafana) is not running — Monitoring dashboards will be empty.'
|
|
150
|
+
: '');
|
|
151
|
+
|
|
125
152
|
if (!poll) {
|
|
126
153
|
const result = await checkOnce();
|
|
127
154
|
const message = result.healthy
|
|
128
|
-
? 'Relayer core services (UI + S3) are healthy.' + (result.all_healthy ? ' HostIO is also healthy.' : ' HostIO status is pending authentication.')
|
|
155
|
+
? 'Relayer core services (UI + S3) are healthy.' + (result.all_healthy ? ' HostIO is also healthy.' : ' HostIO status is pending authentication.') + degradedSuffix(result)
|
|
129
156
|
: `Relayer is not yet healthy. Unhealthy: ${result.unhealthy.join(', ')}.`;
|
|
130
157
|
|
|
131
158
|
return {
|
|
@@ -163,9 +190,9 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
|
163
190
|
};
|
|
164
191
|
}
|
|
165
192
|
|
|
166
|
-
const message = result.all_healthy
|
|
193
|
+
const message = (result.all_healthy
|
|
167
194
|
? 'All Relayer services are healthy (UI, S3, HostIO). Ready for claim.'
|
|
168
|
-
: 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.';
|
|
195
|
+
: 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.') + degradedSuffix(result);
|
|
169
196
|
|
|
170
197
|
return {
|
|
171
198
|
content: [{
|
|
@@ -4,8 +4,14 @@ const { z } = require('zod');
|
|
|
4
4
|
const path = require('path');
|
|
5
5
|
const { createDockerUtil } = require('../lib/dockerUtil');
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
//
|
|
7
|
+
// Canonical released install — the full beta channel bundle (relayer +
|
|
8
|
+
// monitoring stack). Versioned in the deploy repo, shipped to web01 by
|
|
9
|
+
// `deploy.py promote`, served login-free. THE default install source.
|
|
10
|
+
const CHANNEL_COMPOSE_URL = 'https://releases.scpri.me/relayer/beta/docker-compose.yml';
|
|
11
|
+
|
|
12
|
+
// Bundled OFFLINE FALLBACK template (ships in the npm package; package.json
|
|
13
|
+
// `files: ["src/"]` covers it). Written only when the channel fetch fails;
|
|
14
|
+
// kept service-parity with the channel bundle by the jest contract tests.
|
|
9
15
|
const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
|
|
10
16
|
|
|
11
17
|
// container_name in the bundled compose. Docker container names are unique
|
|
@@ -19,11 +25,12 @@ const CONTAINER_NAME = 'xns-relayer';
|
|
|
19
25
|
* TP-30: uses execFile, no shell (security non-negotiable).
|
|
20
26
|
*
|
|
21
27
|
* A first-time user has never heard of a Relayer and cannot supply a compose
|
|
22
|
-
* file or a .env. So by default this tool
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
28
|
+
* file or a .env. So by default this tool authors both for them: it fetches
|
|
29
|
+
* the CANONICAL channel bundle (relayer + Prometheus + Grafana + node-exporter
|
|
30
|
+
* — the monitoring stack powers the dashboards under Monitoring in the web UI)
|
|
31
|
+
* and writes a .env carrying the two ports. If the fetch fails (offline,
|
|
32
|
+
* registry hiccup), the bundled service-parity template is the fallback — the
|
|
33
|
+
* install still completes and the response says it fell back.
|
|
27
34
|
*
|
|
28
35
|
* `compose_url` stays as an optional override for internal/custom installs;
|
|
29
36
|
* when given, the old download-a-URL behaviour is preserved.
|
|
@@ -32,10 +39,11 @@ module.exports = function registerInstallRelayer(server, options = {}) {
|
|
|
32
39
|
const docker = options.dockerUtil || createDockerUtil(options);
|
|
33
40
|
const _execFile = options.execFile;
|
|
34
41
|
const fsp = options.fs || require('fs').promises;
|
|
42
|
+
const channelComposeUrl = options.channelComposeUrl || CHANNEL_COMPOSE_URL;
|
|
35
43
|
|
|
36
44
|
server.tool(
|
|
37
45
|
'install_relayer',
|
|
38
|
-
'Install and start the XNS Relayer. By default
|
|
46
|
+
'Install and start the XNS Relayer. By default fetches the canonical beta channel bundle — relayer + the Prometheus/Grafana monitoring stack — from releases.scpri.me (anonymous pull) and writes a .env, then runs docker compose up -d — the user does NOT need to author any file. Falls back to a bundled copy of the bundle if the fetch fails. Pass compose_url only to override with a custom compose.',
|
|
39
47
|
{
|
|
40
48
|
install_path: z.string().optional().default('/opt/xns-relayer').describe('Directory to install the compose file into'),
|
|
41
49
|
ui_port: z.number().int().positive().optional().default(8888).describe('Host port for the Relayer admin/customer UI (container 8888)'),
|
|
@@ -77,18 +85,33 @@ module.exports = function registerInstallRelayer(server, options = {}) {
|
|
|
77
85
|
});
|
|
78
86
|
});
|
|
79
87
|
|
|
88
|
+
const fetchCompose = (url) => new Promise((resolve, reject) => {
|
|
89
|
+
execFileFn('curl', ['-fsSL', '-o', composePath, url], { timeout: 60000 }, (err) => {
|
|
90
|
+
if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let source;
|
|
96
|
+
let note;
|
|
80
97
|
if (compose_url) {
|
|
81
98
|
// Override path: download a custom compose (execFile, no shell).
|
|
82
|
-
await
|
|
83
|
-
|
|
84
|
-
if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
|
|
85
|
-
resolve();
|
|
86
|
-
});
|
|
87
|
-
});
|
|
99
|
+
await fetchCompose(compose_url);
|
|
100
|
+
source = 'compose_url';
|
|
88
101
|
} else {
|
|
89
|
-
// Default path:
|
|
90
|
-
|
|
91
|
-
|
|
102
|
+
// Default path: fetch the canonical channel bundle (relayer +
|
|
103
|
+
// monitoring stack); fall back to the bundled service-parity
|
|
104
|
+
// template only when the fetch fails. Either way, author the
|
|
105
|
+
// .env so the user never writes a file.
|
|
106
|
+
try {
|
|
107
|
+
await fetchCompose(channelComposeUrl);
|
|
108
|
+
source = 'channel';
|
|
109
|
+
} catch (fetchErr) {
|
|
110
|
+
const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
|
|
111
|
+
await fsp.writeFile(composePath, template);
|
|
112
|
+
source = 'bundled-fallback';
|
|
113
|
+
note = `Channel bundle fetch failed (${fetchErr.message}) — fell back to the bundled compose. Same services; re-running install later is not required.`;
|
|
114
|
+
}
|
|
92
115
|
await fsp.writeFile(envPath, `UI_PORT=${ui_port}\nS3_PORT=${s3_port}\n`);
|
|
93
116
|
}
|
|
94
117
|
|
|
@@ -108,7 +131,8 @@ module.exports = function registerInstallRelayer(server, options = {}) {
|
|
|
108
131
|
message: 'XNS Relayer containers are starting. Use check_relayer_health to monitor when all services are ready.',
|
|
109
132
|
compose_path: composePath,
|
|
110
133
|
install_path,
|
|
111
|
-
source
|
|
134
|
+
source,
|
|
135
|
+
...(note ? { note } : {}),
|
|
112
136
|
}, null, 2),
|
|
113
137
|
}],
|
|
114
138
|
};
|
|
@@ -8,6 +8,10 @@ const { createDockerUtil } = require('../lib/dockerUtil');
|
|
|
8
8
|
* Tool 10: verify_storage
|
|
9
9
|
* AC-20: targets the S3 gateway on port 9000 plain HTTP; reports endpoint.
|
|
10
10
|
* AC-21: verify fail → names the failing step (CreateBucket/PutObject/GetObject).
|
|
11
|
+
* W3-AC1: cleanup in finally — always removes mcp-verify-* bucket/object; failure
|
|
12
|
+
* is non-fatal (noted in response, never flips pass→fail).
|
|
13
|
+
* W3-AC2: fullaccess credential requirement stated in description and error output.
|
|
14
|
+
* W3-AC3: explicit-IP hint when endpoint was auto-detected.
|
|
11
15
|
* R6: S3 credentials must be provided (from relayer-ui IAM key-mgmt flow).
|
|
12
16
|
*
|
|
13
17
|
* Performs a round-trip verification: CreateBucket → PutObject → GetObject → compare.
|
|
@@ -20,22 +24,26 @@ module.exports = function registerVerifyStorage(server, options = {}) {
|
|
|
20
24
|
|
|
21
25
|
server.tool(
|
|
22
26
|
'verify_storage',
|
|
23
|
-
'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. By default targets port 9000 on the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass endpoint to override
|
|
27
|
+
'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. IMPORTANT: you must supply fullaccess credentials — the admin key pair created via the Relayer UI IAM page (not a read-only or bucket-scoped key). By default targets port 9000 on the machine the Docker daemon runs on (auto-detected from the Docker context — supports remote ssh:// Docker hosts); pass endpoint to override with an explicit IP when auto-detection cannot reach the host.',
|
|
24
28
|
{
|
|
25
|
-
access_key_id: z.string().describe('S3 access key ID'),
|
|
26
|
-
secret_access_key: z.string().describe('S3 secret access key'),
|
|
27
|
-
endpoint: z.string().trim().url().optional().describe('S3 endpoint URL. Default: http://{docker-host}:9000, where {docker-host} is auto-detected from the Docker context.'),
|
|
29
|
+
access_key_id: z.string().describe('S3 access key ID (fullaccess credentials from the Relayer UI IAM page)'),
|
|
30
|
+
secret_access_key: z.string().describe('S3 secret access key (fullaccess credentials from the Relayer UI IAM page)'),
|
|
31
|
+
endpoint: z.string().trim().url().optional().describe('S3 endpoint URL. Default: http://{docker-host}:9000, where {docker-host} is auto-detected from the Docker context. Pass an explicit IP (e.g. http://192.168.1.100:9000) when auto-detection cannot reach the host.'),
|
|
28
32
|
},
|
|
29
33
|
async ({ access_key_id, secret_access_key, endpoint }) => {
|
|
30
34
|
const testBucket = `mcp-verify-${Date.now()}`;
|
|
31
35
|
const testKey = 'verify-test.txt';
|
|
32
36
|
const testContent = `relayer-mcp verification ${Date.now()}`;
|
|
33
37
|
let currentStep = 'init';
|
|
38
|
+
let endpointAutoDetected = false;
|
|
39
|
+
|
|
40
|
+
let verifyResult = null;
|
|
34
41
|
|
|
35
42
|
try {
|
|
36
43
|
if (!endpoint) {
|
|
37
44
|
const { host } = await docker.getDockerHost();
|
|
38
45
|
endpoint = `http://${host}:9000`;
|
|
46
|
+
endpointAutoDetected = true;
|
|
39
47
|
}
|
|
40
48
|
const s3 = _createS3Client({
|
|
41
49
|
endpoint,
|
|
@@ -58,42 +66,83 @@ module.exports = function registerVerifyStorage(server, options = {}) {
|
|
|
58
66
|
// Step 4: Compare
|
|
59
67
|
currentStep = 'Compare';
|
|
60
68
|
if (retrieved !== testContent) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
verifyResult = {
|
|
70
|
+
success: false,
|
|
71
|
+
error: 'Storage verification failed at Compare: uploaded content does not match downloaded content. The S3 gateway may have data integrity issues.',
|
|
72
|
+
endpoint,
|
|
73
|
+
failing_step: 'Compare',
|
|
74
|
+
};
|
|
75
|
+
} else {
|
|
76
|
+
const explicitIpHint = endpointAutoDetected
|
|
77
|
+
? `If auto-detection cannot reach the host, pass an explicit endpoint: endpoint option (e.g. "http://<ip>:9000").`
|
|
78
|
+
: null;
|
|
79
|
+
|
|
80
|
+
verifyResult = {
|
|
81
|
+
success: true,
|
|
82
|
+
message: `S3 storage verification passed. Successfully created bucket, uploaded object, and verified download at ${endpoint}.`,
|
|
83
|
+
endpoint,
|
|
84
|
+
test_bucket: testBucket,
|
|
85
|
+
...(explicitIpHint ? { note: explicitIpHint } : {}),
|
|
72
86
|
};
|
|
73
87
|
}
|
|
74
88
|
|
|
89
|
+
// Cleanup: remove test object and bucket (W3-AC1)
|
|
90
|
+
try {
|
|
91
|
+
await s3.deleteObject(testBucket, testKey);
|
|
92
|
+
await s3.deleteBucket(testBucket);
|
|
93
|
+
} catch (cleanupErr) {
|
|
94
|
+
// Non-fatal: note it but never flip the result
|
|
95
|
+
verifyResult.cleanup_warning = `Test bucket cleanup failed (${cleanupErr.message}). The bucket "${testBucket}" may remain — remove it manually via the Relayer UI.`;
|
|
96
|
+
}
|
|
97
|
+
|
|
75
98
|
return {
|
|
76
99
|
content: [{
|
|
77
100
|
type: 'text',
|
|
78
|
-
text: JSON.stringify(
|
|
79
|
-
success: true,
|
|
80
|
-
message: `S3 storage verification passed. Successfully created bucket, uploaded object, and verified download at ${endpoint}.`,
|
|
81
|
-
endpoint,
|
|
82
|
-
test_bucket: testBucket,
|
|
83
|
-
}, null, 2),
|
|
101
|
+
text: JSON.stringify(verifyResult, null, 2),
|
|
84
102
|
}],
|
|
103
|
+
...(verifyResult.success ? {} : { isError: true }),
|
|
85
104
|
};
|
|
86
105
|
} catch (err) {
|
|
106
|
+
// Attempt cleanup even on error (W3-AC1)
|
|
107
|
+
let cleanupWarning;
|
|
108
|
+
if (currentStep !== 'init' && currentStep !== 'CreateBucket') {
|
|
109
|
+
// Bucket was created before the failure — try to clean up
|
|
110
|
+
try {
|
|
111
|
+
const s3Cleanup = _createS3Client({
|
|
112
|
+
endpoint,
|
|
113
|
+
accessKeyId: access_key_id,
|
|
114
|
+
secretAccessKey: secret_access_key,
|
|
115
|
+
});
|
|
116
|
+
await s3Cleanup.deleteObject(testBucket, testKey).catch(() => {});
|
|
117
|
+
await s3Cleanup.deleteBucket(testBucket);
|
|
118
|
+
} catch (cleanupErr) {
|
|
119
|
+
cleanupWarning = `Test bucket cleanup failed (${cleanupErr.message}). The bucket "${testBucket}" may remain — remove it manually.`;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const isCredentialError = /AccessDenied|InvalidAccessKeyId|SignatureDoesNotMatch|Forbidden|401|403/i.test(err.message);
|
|
124
|
+
const credentialHint = isCredentialError
|
|
125
|
+
? ' Ensure you are using fullaccess credentials from the Relayer UI IAM page (not a read-only or bucket-scoped key).'
|
|
126
|
+
: '';
|
|
127
|
+
|
|
128
|
+
const explicitIpHint = endpointAutoDetected
|
|
129
|
+
? ` If the endpoint cannot be reached, pass an explicit IP via the endpoint option (e.g. "http://<ip>:9000").`
|
|
130
|
+
: '';
|
|
131
|
+
|
|
87
132
|
// AC-21: name the failing step
|
|
133
|
+
const errorPayload = {
|
|
134
|
+
success: false,
|
|
135
|
+
error: `Storage verification failed at ${currentStep}: ${err.message}.${credentialHint}${explicitIpHint}`,
|
|
136
|
+
endpoint,
|
|
137
|
+
failing_step: currentStep,
|
|
138
|
+
...(isCredentialError ? { credential_requirement: 'fullaccess credentials required — use the admin key pair from the Relayer UI IAM page.' } : {}),
|
|
139
|
+
...(cleanupWarning ? { cleanup_warning: cleanupWarning } : {}),
|
|
140
|
+
};
|
|
141
|
+
|
|
88
142
|
return {
|
|
89
143
|
content: [{
|
|
90
144
|
type: 'text',
|
|
91
|
-
text: JSON.stringify(
|
|
92
|
-
success: false,
|
|
93
|
-
error: `Storage verification failed at ${currentStep}: ${err.message}`,
|
|
94
|
-
endpoint,
|
|
95
|
-
failing_step: currentStep,
|
|
96
|
-
}),
|
|
145
|
+
text: JSON.stringify(errorPayload),
|
|
97
146
|
}],
|
|
98
147
|
isError: true,
|
|
99
148
|
};
|