@xns-cloud/relayer-mcp 0.3.0 → 0.5.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/README.md CHANGED
@@ -1,10 +1,30 @@
1
1
  # @xns-cloud/relayer-mcp
2
2
 
3
- MCP server for XNS Relayer onboarding. Provides 10 tools that let an AI agent drive the complete Relayer setup conversationally over stdio transport.
3
+ MCP server for XNS Relayer onboarding. Provides 11 tools that let an AI agent drive the complete Relayer setup conversationally over stdio transport.
4
4
 
5
- ## Claude Desktop Configuration
5
+ ## Requirements
6
6
 
7
- Add to your Claude Desktop `claude_desktop_config.json`:
7
+ - **Node.js 20+** — see [Installing Node.js 20](#installing-nodejs-20) if your distro ships an older version.
8
+ - **Docker Engine** — on the same machine, or on a remote host via a Docker context (see [Remote Docker hosts](#remote-docker-hosts)).
9
+
10
+ ### Installing Node.js 20
11
+
12
+ Ubuntu's default apt repository only ships Node 18, which is too old. Two ways to get Node 20:
13
+
14
+ **nvm** (recommended — no root required):
15
+
16
+ ```bash
17
+ curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash
18
+ \. "$HOME/.nvm/nvm.sh" && nvm install 20
19
+ ```
20
+
21
+ **NodeSource** (system-wide): follow <https://github.com/nodesource/distributions#installation-instructions>.
22
+
23
+ If you start the MCP on an older Node, it exits immediately with this same guidance instead of a dependency stack trace.
24
+
25
+ ## Claude Desktop / Claude Code Configuration
26
+
27
+ Add to your `claude_desktop_config.json` (or `claude mcp add relayer -- npx @xns-cloud/relayer-mcp@latest` for Claude Code):
8
28
 
9
29
  ```json
10
30
  {
@@ -23,16 +43,17 @@ No separate install step required.
23
43
 
24
44
  | # | Tool | Purpose |
25
45
  |---|------|---------|
26
- | 1 | `check_prerequisites` | Verify Docker, ports (8888, 9000), disk, and network connectivity. |
46
+ | 1 | `check_prerequisites` | Verify Docker (local or remote), ports (8888, 9000), an existing installation, disk, and network connectivity. |
27
47
  | 2 | `register_account` | Register an XNS account (email + password) via Console2. |
28
48
  | 3 | `check_email_verified` | Poll email verification status (15s interval, 30-min timeout). |
29
- | 4 | `install_relayer` | Write the bundled `docker-compose.yml` + `.env` (Docker Hub `scprime/xns-relayer`, pre-release `:beta` channel) and start the containers. The user authors nothing; `compose_url` is an optional override. |
30
- | 5 | `check_relayer_health` | Poll UI, S3, and HostIO health (10s interval, 300s timeout). |
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. |
31
51
  | 6 | `start_claim` | Initiate a claim session — returns a URL for browser confirmation. |
32
52
  | 7 | `check_claim_status` | Poll claim state (STATE_1 / STATE_2 / STATE_3). |
33
53
  | 8 | `get_host_tags` | Retrieve available host tags for VPD configuration. |
34
54
  | 9 | `configure_vpd` | Set data/parity host selection via CEL expressions. |
35
- | 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) at localhost:9000. |
55
+ | 10 | `verify_storage` | Round-trip S3 test (create bucket, put object, get object) against the S3 gateway on port 9000. |
56
+ | 11 | `setup_cli_credentials` | Provision S3 IAM credentials and write `~/.xns/credentials` so the XNS CLI works without further configuration. |
36
57
 
37
58
  ## Onboarding Flow
38
59
 
@@ -45,9 +66,50 @@ No separate install step required.
45
66
  6. Agent initiates claim; user opens claim URL in browser (Tools 6 + 7).
46
67
  7. Agent signs in via OIDC to configure host preferences (Tools 8 + 9).
47
68
  8. Agent verifies S3 storage is working (Tool 10).
69
+ 9. Optionally, agent provisions CLI credentials (Tool 11).
48
70
 
49
71
  The operator's only required actions are: clicking one email link, completing one browser sign-in, and confirming one claim.
50
72
 
73
+ ## Fresh installs vs. existing deployments
74
+
75
+ `install_relayer` performs **fresh installs only** — it does not upgrade an existing deployment in place. Docker container names are unique per daemon, so any existing `xns-relayer` container (running **or stopped**, any channel — including an alpha-channel install from `releases.scpri.me`) blocks the install. Both `check_prerequisites` and `install_relayer` detect this and tell you before anything breaks.
76
+
77
+ To replace an existing deployment:
78
+
79
+ ```bash
80
+ docker stop xns-relayer && docker rm xns-relayer # does NOT delete the data directory
81
+ ```
82
+
83
+ then run `install_relayer` again. To keep the existing deployment, skip `install_relayer` and continue onboarding against it (`check_relayer_health` onwards).
84
+
85
+ ## Remote Docker hosts
86
+
87
+ Claude Code doesn't have to run on the Docker machine. If you run it on a management node or jump host, point the Docker CLI at the remote server with an SSH context:
88
+
89
+ ```bash
90
+ docker context create relayer --docker "host=ssh://user@docker-box"
91
+ docker context use relayer
92
+ ```
93
+
94
+ (Requires the `docker` CLI on the management node — the [static binary](https://docs.docker.com/engine/install/binaries/) is enough — and SSH key access to the Docker host.)
95
+
96
+ The MCP detects this automatically (it honors `DOCKER_HOST` and the active Docker context):
97
+
98
+ - `install_relayer` runs `docker compose` against the remote daemon.
99
+ - `check_relayer_health` and `verify_storage` probe the **remote host's** ports 8888/9000 instead of localhost — make sure those are reachable from the management node.
100
+ - `check_prerequisites` skips the local port-availability probes (the containers bind ports on the remote host) and reports them as skipped with instructions.
101
+
102
+ `check_relayer_health` accepts a `host` override, and `verify_storage` an `endpoint` override, for setups the auto-detection can't see (port forwards, NAT).
103
+
104
+ ## Troubleshooting
105
+
106
+ | Symptom | Cause | Fix |
107
+ |---|---|---|
108
+ | MCP exits with "requires Node.js 20 or newer" | Distro Node is too old (Ubuntu apt ships Node 18) | [Installing Node.js 20](#installing-nodejs-20) |
109
+ | `install_relayer` reports an existing `xns-relayer` container | A previous deployment (any channel) owns the container name | [Fresh installs vs. existing deployments](#fresh-installs-vs-existing-deployments) |
110
+ | Port 8888/9000 already in use | Another service on the Docker host (another S3-compatible service squatting 9000) | Stop it, or install with custom ports: `install_relayer` `ui_port` / `s3_port` (health checks accept the same) |
111
+ | Health checks fail but containers run on a remote Docker host | Ports 8888/9000 not reachable from the management node | Open them, or pass `host` / `endpoint` overrides |
112
+
51
113
  ## Authentication
52
114
 
53
115
  Tools 8 and 9 require an OIDC token to access the HostIO proxy. The MCP acquires one automatically using Authorization Code + PKCE (S256) flow against the `scprime` Keycloak realm with the `relayer-native` public client. The user completes a browser sign-in; the MCP captures the code on a local `127.0.0.1` loopback listener and exchanges it for a token.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xns-cloud/relayer-mcp",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
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",
@@ -17,7 +17,8 @@
17
17
  },
18
18
  "scripts": {
19
19
  "test": "jest --verbose",
20
- "start": "node src/index.js"
20
+ "start": "node src/index.js",
21
+ "prepublishOnly": "npm test"
21
22
  },
22
23
  "keywords": [
23
24
  "mcp",
@@ -46,7 +47,9 @@
46
47
  "jest": "^30.4.2"
47
48
  },
48
49
  "jest": {
49
- "testMatch": ["**/src/__tests__/**/*.test.js"],
50
+ "testMatch": [
51
+ "**/src/__tests__/**/*.test.js"
52
+ ],
50
53
  "testEnvironment": "node",
51
54
  "forceExit": true
52
55
  }
package/src/index.js CHANGED
@@ -1,12 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  'use strict';
3
3
 
4
+ // Node 20+ guard — runs BEFORE the SDK loads so users on older runtimes get a
5
+ // remediation message instead of a SyntaxError from a dependency.
6
+ const { checkNodeVersion } = require('./lib/nodeVersion');
7
+ const nodeCheck = checkNodeVersion(process.versions.node);
8
+ if (!nodeCheck.ok) {
9
+ process.stderr.write(nodeCheck.message);
10
+ process.exit(1);
11
+ }
12
+
4
13
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
5
14
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
6
15
 
7
16
  const server = new McpServer({
8
17
  name: 'relayer-mcp',
9
- version: '0.1.0',
18
+ version: require('../package.json').version,
10
19
  });
11
20
 
12
21
  // Register all 11 tools
@@ -2,6 +2,30 @@
2
2
 
3
3
  const { execFile: nodeExecFile } = require('child_process');
4
4
 
5
+ /**
6
+ * Parse a Docker endpoint URL into { remote, host }.
7
+ *
8
+ * unix:// and npipe:// sockets are local by definition. ssh:// and tcp://
9
+ * point at another machine — unless the hostname is loopback. Anything
10
+ * unparseable falls back to local, matching pre-context behaviour.
11
+ *
12
+ * @param {string} endpoint - e.g. 'unix:///var/run/docker.sock', 'ssh://user@host'
13
+ * @returns {{remote: boolean, host: string}}
14
+ */
15
+ function parseDockerEndpoint(endpoint) {
16
+ const local = { remote: false, host: 'localhost' };
17
+ if (!endpoint) return local;
18
+ if (endpoint.startsWith('unix://') || endpoint.startsWith('npipe://')) return local;
19
+ try {
20
+ const { hostname } = new URL(endpoint);
21
+ // URL.hostname keeps IPv6 brackets: 'tcp://[::1]:2375' → '[::1]'
22
+ if (!hostname || ['localhost', '127.0.0.1', '[::1]'].includes(hostname)) return local;
23
+ return { remote: true, host: hostname };
24
+ } catch {
25
+ return local;
26
+ }
27
+ }
28
+
5
29
  /**
6
30
  * Docker utility — runs Docker CLI commands using execFile (no shell).
7
31
  * Security non-negotiable: NEVER use exec() or spawn({ shell: true }).
@@ -9,9 +33,11 @@ const { execFile: nodeExecFile } = require('child_process');
9
33
  *
10
34
  * @param {object} [options]
11
35
  * @param {function} [options.execFile] - Injected execFile (testing)
36
+ * @param {object} [options.env] - Injected environment (testing); defaults to process.env
12
37
  */
13
38
  function createDockerUtil(options = {}) {
14
39
  const _execFile = options.execFile || nodeExecFile;
40
+ const _env = options.env || process.env;
15
41
 
16
42
  /**
17
43
  * Run a docker command with args.
@@ -62,7 +88,62 @@ function createDockerUtil(options = {}) {
62
88
  }
63
89
  }
64
90
 
65
- return { docker, composeUp, isContainerRunning };
91
+ /**
92
+ * Find a container by EXACT name — running or stopped. A stopped container
93
+ * still owns its name and still breaks `docker compose up`, so callers
94
+ * doing install preflight must use this, not isContainerRunning.
95
+ *
96
+ * Best-effort: if docker itself is unreachable, returns null and lets the
97
+ * caller's real docker command surface the error.
98
+ *
99
+ * @param {string} name - Exact container name
100
+ * @returns {Promise<{name: string, status: string, image: string, running: boolean}|null>}
101
+ */
102
+ async function findContainer(name) {
103
+ try {
104
+ // docker ps --filter name= treats the value as a regex — escape
105
+ // metacharacters (names may contain '.') so the anchor stays exact.
106
+ const escaped = String(name).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
107
+ const { stdout } = await docker([
108
+ 'ps', '-a',
109
+ '--filter', `name=^${escaped}$`,
110
+ '--format', '{{.Names}}\t{{.Status}}\t{{.Image}}',
111
+ ]);
112
+ const line = stdout.trim().split('\n').filter(Boolean)[0];
113
+ if (!line) return null;
114
+ const [foundName, status = '', image = ''] = line.split('\t');
115
+ return { name: foundName, status, image, running: status.toLowerCase().startsWith('up') };
116
+ } catch {
117
+ return null;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Resolve which machine the Docker daemon actually runs on.
123
+ *
124
+ * Claude Code may run on a management node with the Docker CLI pointed at a
125
+ * separate server (DOCKER_HOST or an ssh:// docker context). Tools that
126
+ * probe ports or hit http://localhost must target THIS host instead.
127
+ *
128
+ * Precedence mirrors the Docker CLI: DOCKER_HOST env var wins, then the
129
+ * active context's endpoint. Unparseable/missing → local.
130
+ *
131
+ * @returns {Promise<{remote: boolean, host: string, endpoint: string|null}>}
132
+ */
133
+ async function getDockerHost() {
134
+ if (_env.DOCKER_HOST) {
135
+ return { ...parseDockerEndpoint(_env.DOCKER_HOST), endpoint: _env.DOCKER_HOST };
136
+ }
137
+ try {
138
+ const { stdout } = await docker(['context', 'inspect', '--format', '{{.Endpoints.docker.Host}}']);
139
+ const endpoint = stdout.trim();
140
+ return { ...parseDockerEndpoint(endpoint), endpoint: endpoint || null };
141
+ } catch {
142
+ return { remote: false, host: 'localhost', endpoint: null };
143
+ }
144
+ }
145
+
146
+ return { docker, composeUp, isContainerRunning, findContainer, getDockerHost };
66
147
  }
67
148
 
68
- module.exports = { createDockerUtil };
149
+ module.exports = { createDockerUtil, parseDockerEndpoint };
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // Keep this file's syntax conservative — it must PARSE on old Node versions
4
+ // (Ubuntu's default apt repo ships Node 18) so the friendly message below is
5
+ // what the user sees, not a SyntaxError from a dependency.
6
+
7
+ const MIN_MAJOR = 20;
8
+
9
+ /**
10
+ * Check a Node.js version string against the package's engines floor.
11
+ *
12
+ * @param {string} version - e.g. process.versions.node ('18.19.1')
13
+ * @returns {{ok: boolean, major: number, message: string|null}}
14
+ */
15
+ function checkNodeVersion(version) {
16
+ const major = parseInt(String(version).split('.')[0], 10) || 0;
17
+ if (major >= MIN_MAJOR) {
18
+ return { ok: true, major, message: null };
19
+ }
20
+ const message = [
21
+ `relayer-mcp requires Node.js ${MIN_MAJOR} or newer — you are running v${version}.`,
22
+ '',
23
+ "Ubuntu's default apt repository only ships Node 18. Install Node 20 with nvm:",
24
+ '',
25
+ ' curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash',
26
+ ' \\. "$HOME/.nvm/nvm.sh" && nvm install 20',
27
+ '',
28
+ 'Or via NodeSource: https://github.com/nodesource/distributions#installation-instructions',
29
+ '',
30
+ ].join('\n');
31
+ return { ok: false, major, message };
32
+ }
33
+
34
+ module.exports = { checkNodeVersion, MIN_MAJOR };
@@ -1,27 +1,77 @@
1
- # Bundled by @xns-cloud/relayer-mcp — the documented released XNS Relayer install.
2
- # Source of truth: https://xns.tech/docs/windows-ui/ (Docker Hub scprime/xns-relayer).
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.
3
9
  #
4
- # Pre-release: pinned to the :beta channel so alpha/beta testers exercise the
5
- # current Relayer agentically. Flip this one tag to :stable at general release.
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).
6
14
  #
7
- # A first-time user no longer hand-writes this file or the .env — install_relayer
8
- # writes both. Differences from the hand-written docs compose, by design:
9
- # - data lives in ./data (relative to this file) instead of a Windows-only
10
- # C:\XNS-Relayer bind, so the same file works on Windows, macOS, and Linux.
15
+ # Pre-release: pinned to :beta-latest so beta testers exercise the current
16
+ # Relayer agentically. Flip these tags to :stable-latest at general release.
17
+ #
18
+ # A first-time user never hand-writes this file or the .env install_relayer
19
+ # writes both. Differences from the channel compose, by design:
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.
11
23
  # - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
12
24
  # SMB on consumer hosts and would fail the install. S3 onboarding uses :9000.
13
- # - pull_policy: always so `docker compose up -d` fetches the current :beta
14
- # image without a separate `docker compose pull` step.
25
+ # - pull_policy: always so `docker compose up -d` fetches the current beta
26
+ # images without a separate `docker compose pull` step.
15
27
  services:
16
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.
17
31
  container_name: xns-relayer
18
- image: scprime/xns-relayer:beta
32
+ image: releases.scpri.me/xns-relayer:beta-latest
19
33
  pull_policy: always
20
34
  restart: unless-stopped
35
+ privileged: true # samba / fuse / disk management (matches channel compose)
21
36
  volumes:
22
37
  - ./data:/relayer
23
38
  ports:
24
39
  - ${UI_PORT:-8888}:8888
25
- - ${MINIO_PORT:-9000}:9000
40
+ - ${S3_PORT:-9000}:9000
26
41
  env_file:
27
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:
@@ -35,7 +35,7 @@ module.exports = function registerCheckPrerequisites(server, options = {}) {
35
35
 
36
36
  server.tool(
37
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.',
38
+ 'Check system prerequisites for XNS Relayer installation: Docker availability (local or remote via DOCKER_HOST / ssh:// context), required ports (8888, 9000), an existing xns-relayer installation, disk space, and network connectivity to console.xns.tech and auth.xns.tech. Run this first before any other relayer tool.',
39
39
  {
40
40
  /* no parameters */
41
41
  },
@@ -43,26 +43,48 @@ module.exports = function registerCheckPrerequisites(server, options = {}) {
43
43
  const checks = [];
44
44
  let allPassed = true;
45
45
 
46
- // 1. Docker available
46
+ // 1. Docker available — and WHERE it runs. With DOCKER_HOST or an
47
+ // ssh:// context (e.g. Claude Code on a jump host), the daemon is
48
+ // on another machine and the local checks below must adapt.
49
+ let dockerHost = { remote: false, host: 'localhost', endpoint: null };
47
50
  try {
48
51
  await docker.docker(['info', '--format', '{{.ServerVersion}}']);
49
- checks.push({ name: 'docker', passed: true, detail: 'Docker is running' });
52
+ dockerHost = await docker.getDockerHost();
53
+ checks.push({
54
+ name: 'docker',
55
+ passed: true,
56
+ detail: dockerHost.remote
57
+ ? `Docker is running on a remote host (${dockerHost.host}, via ${dockerHost.endpoint})`
58
+ : 'Docker is running',
59
+ });
50
60
  } catch (err) {
51
61
  allPassed = false;
52
62
  checks.push({
53
63
  name: 'docker',
54
64
  passed: false,
55
65
  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',
66
+ remediation: 'Install Docker Engine (https://docs.docker.com/engine/install/) and ensure the Docker daemon is running. On Linux: sudo systemctl start docker. Remote daemon: create an SSH context — docker context create relayer --docker "host=ssh://user@host" && docker context use relayer',
57
67
  });
58
68
  }
59
69
 
60
- // 2-3. Required ports
70
+ // 2-3. Required ports. The bind-probe runs on THIS machine — when
71
+ // the Docker daemon is remote, the containers (and their port
72
+ // bindings) live on the remote host, so a local probe would test
73
+ // the wrong machine. Report skipped instead of a false answer.
61
74
  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).' },
75
+ { 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 install with a different port via install_relayer ui_port.' },
76
+ { 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: another S3-compatible service), or install with a different port via install_relayer s3_port.' },
64
77
  ];
65
78
  for (const { port, name, inUseRemediation } of requiredPorts) {
79
+ if (dockerHost.remote) {
80
+ checks.push({
81
+ name,
82
+ passed: true,
83
+ skipped: true,
84
+ detail: `Port ${port} check skipped — the Docker daemon runs on ${dockerHost.host}, so port availability must be checked there (e.g. ss -tlnp | grep ${port} on that host).`,
85
+ });
86
+ continue;
87
+ }
66
88
  try {
67
89
  const available = await _checkPort(port);
68
90
  if (available) {
@@ -77,6 +99,26 @@ module.exports = function registerCheckPrerequisites(server, options = {}) {
77
99
  }
78
100
  }
79
101
 
102
+ // 3b. Existing installation. install_relayer is fresh-install only;
103
+ // an existing xns-relayer container (running or stopped, any
104
+ // channel) would fail it with a name conflict. Warn early, here.
105
+ try {
106
+ const existing = await docker.findContainer('xns-relayer');
107
+ if (existing) {
108
+ checks.push({
109
+ name: 'existing_install',
110
+ passed: true,
111
+ warning: true,
112
+ detail: `An existing 'xns-relayer' container was found (status: ${existing.status}; image: ${existing.image}).`,
113
+ remediation: 'install_relayer performs fresh installs only. To replace the existing deployment: docker stop xns-relayer && docker rm xns-relayer (data directory is preserved), then install. To keep it, skip install_relayer and continue onboarding against the running deployment.',
114
+ });
115
+ } else {
116
+ checks.push({ name: 'existing_install', passed: true, detail: 'No existing xns-relayer container — ready for a fresh install' });
117
+ }
118
+ } catch {
119
+ checks.push({ name: 'existing_install', passed: true, detail: 'Existing-install check skipped (Docker not reachable)' });
120
+ }
121
+
80
122
  // 4. Disk space (need at least 10 GB free — basic docker images + data)
81
123
  try {
82
124
  await docker.docker(['system', 'df', '--format', '{{.TotalCount}}']);
@@ -91,6 +133,9 @@ module.exports = function registerCheckPrerequisites(server, options = {}) {
91
133
  const connectivityChecks = [
92
134
  { url: 'https://console.xns.tech/health', name: 'connectivity_console', host: 'console.xns.tech' },
93
135
  { url: 'https://auth.xns.tech/auth/realms/scprime/.well-known/openid-configuration', name: 'connectivity_auth', host: 'auth.xns.tech' },
136
+ // The registry install_relayer pulls from (beta channel, anonymous).
137
+ // /v2/ is the registry version probe — 200 without credentials.
138
+ { url: 'https://releases.scpri.me/v2/', name: 'connectivity_registry', host: 'releases.scpri.me' },
94
139
  ];
95
140
  for (const { url, name, host } of connectivityChecks) {
96
141
  try {
@@ -2,45 +2,89 @@
2
2
 
3
3
  const { z } = require('zod');
4
4
  const { createHttpClient } = require('../lib/httpClient');
5
+ const { createDockerUtil } = require('../lib/dockerUtil');
5
6
  const { pollUntil } = require('../lib/pollUntil');
6
7
 
7
8
  const POLL_INTERVAL_MS = 10000; // 10s
8
9
  const POLL_TIMEOUT_MS = 300000; // 300s (AC-13, QA-1)
9
10
 
11
+ // Loopback aliases — all classify as "local", not a remote Docker host.
12
+ const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
13
+
14
+ /**
15
+ * Normalize a user-supplied or detected host for URL composition: trim, strip
16
+ * ANY accidental scheme prefix (http://, tcp://, ssh://…), bracket bare IPv6.
17
+ * Prevents invalid probe URLs (and false unhealthy reports) from inputs like
18
+ * 'http://docker-box.lan' or a pasted Docker endpoint 'tcp://10.0.0.5:2376'.
19
+ *
20
+ * @param {string} value
21
+ * @returns {string}
22
+ */
23
+ function normalizeHost(value) {
24
+ const trimmed = String(value).trim();
25
+ let hostname = trimmed;
26
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
27
+ try {
28
+ hostname = new URL(trimmed).hostname || trimmed; // keeps IPv6 brackets
29
+ } catch {
30
+ hostname = trimmed;
31
+ }
32
+ }
33
+ // Bare IPv6 → bracket it so http://${host}:${port} stays a valid URL.
34
+ return hostname.includes(':') && !hostname.startsWith('[') ? `[${hostname}]` : hostname;
35
+ }
36
+
10
37
  /**
11
38
  * Tool 5: check_relayer_health
12
39
  * AC-13: report healthy only when ui+s3+hostio healthy; name unhealthy component; 300s timeout.
13
40
  * TP-31: hostio = null (not false) before auth — hostio health requires an authenticated
14
41
  * proxy call, so before OIDC sign-in we report hostio as null (unknown), not false (down).
42
+ *
43
+ * Remote Docker: when the Docker CLI points at another machine (DOCKER_HOST or
44
+ * an ssh:// context — e.g. Claude Code on a jump host), the containers run
45
+ * THERE, so health probes default to that host instead of localhost.
15
46
  */
16
47
  module.exports = function registerCheckRelayerHealth(server, options = {}) {
17
48
  const http = options.httpClient || createHttpClient(options);
49
+ const docker = options.dockerUtil || createDockerUtil(options);
18
50
  const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
19
51
  const pollTimeout = options.pollTimeoutMs ?? POLL_TIMEOUT_MS;
20
52
  const sleep = options.sleep;
21
53
 
22
54
  server.tool(
23
55
  '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.',
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.',
25
57
  {
26
58
  poll: z.boolean().optional().default(true).describe('If true (default), poll until healthy or timeout. If false, check once.'),
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).'),
60
+ ui_port: z.number().int().positive().optional().default(8888).describe('Host port for the Relayer UI (matches install_relayer ui_port)'),
61
+ s3_port: z.number().int().positive().optional().default(9000).describe('Host port for the S3 API (matches install_relayer s3_port)'),
27
62
  },
28
- async ({ poll }) => {
63
+ async ({ poll, host, ui_port, s3_port }) => {
29
64
  try {
65
+ // R1: zod fills defaults in production; ?? guards direct calls.
66
+ const uiPort = ui_port ?? 8888;
67
+ const s3Port = s3_port ?? 9000;
68
+ // Resolve where the containers actually run — once per call.
69
+ const dockerHost = host ? { host, remote: !LOCAL_HOSTS.has(normalizeHost(host)) } : await docker.getDockerHost();
70
+ const target = normalizeHost(dockerHost.host);
71
+ const uiBase = `http://${target}:${uiPort}`;
72
+ const s3Base = `http://${target}:${s3Port}`;
73
+
30
74
  const checkOnce = async () => {
31
75
  const components = {};
32
76
 
33
- // UI health (port 8888)
77
+ // UI health
34
78
  try {
35
- const { status } = await http.get('http://localhost:8888/health', { timeout: 5000 });
79
+ const { status } = await http.get(`${uiBase}/health`, { timeout: 5000 });
36
80
  components.ui = { healthy: status >= 200 && status < 500, status };
37
81
  } catch {
38
82
  components.ui = { healthy: false, status: null };
39
83
  }
40
84
 
41
- // S3 gateway (port 9000)
85
+ // S3 gateway
42
86
  try {
43
- const { status } = await http.get('http://localhost:9000/', { timeout: 5000 });
87
+ const { status } = await http.get(`${s3Base}/`, { timeout: 5000 });
44
88
  components.s3 = { healthy: status >= 200 && status < 500, status };
45
89
  } catch {
46
90
  components.s3 = { healthy: false, status: null };
@@ -49,7 +93,7 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
49
93
  // HostIO — requires auth. Report null (unknown) not false (down).
50
94
  // TP-31: hostio = null before auth
51
95
  try {
52
- const { status } = await http.get('http://localhost:8888/api/v1/proxy/hostio/health', { timeout: 5000 });
96
+ const { status } = await http.get(`${uiBase}/api/v1/proxy/hostio/health`, { timeout: 5000 });
53
97
  if (status === 401) {
54
98
  // Auth required — hostio status is unknown (not unhealthy)
55
99
  components.hostio = { healthy: null, status: 401, note: 'Authentication required — HostIO health unknown until OIDC sign-in' };
@@ -60,26 +104,55 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
60
104
  components.hostio = { healthy: null, status: null, note: 'HostIO health unknown — authentication not yet completed' };
61
105
  }
62
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
+
63
126
  // Healthy if UI and S3 are up. HostIO null (unknown) does NOT block.
64
127
  const coreHealthy = components.ui.healthy === true && components.s3.healthy === true;
65
128
  const allKnownHealthy = coreHealthy && components.hostio.healthy === true;
66
129
 
67
130
  const unhealthy = [];
68
- if (!components.ui.healthy) unhealthy.push('UI (port 8888)');
69
- if (!components.s3.healthy) unhealthy.push('S3 gateway (port 9000)');
131
+ if (!components.ui.healthy) unhealthy.push(`UI (${target}:${uiPort})`);
132
+ if (!components.s3.healthy) unhealthy.push(`S3 gateway (${target}:${s3Port})`);
70
133
 
71
134
  return {
72
135
  components,
73
136
  healthy: coreHealthy,
74
137
  all_healthy: allKnownHealthy,
138
+ // JSON.stringify drops undefined — degraded only appears when true.
139
+ degraded: monitoringHealthy ? undefined : true,
75
140
  unhealthy: unhealthy.length > 0 ? unhealthy : undefined,
141
+ target_host: target,
142
+ remote_docker: dockerHost.remote === true || undefined,
76
143
  };
77
144
  };
78
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
+
79
152
  if (!poll) {
80
153
  const result = await checkOnce();
81
154
  const message = result.healthy
82
- ? '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)
83
156
  : `Relayer is not yet healthy. Unhealthy: ${result.unhealthy.join(', ')}.`;
84
157
 
85
158
  return {
@@ -117,9 +190,9 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
117
190
  };
118
191
  }
119
192
 
120
- const message = result.all_healthy
193
+ const message = (result.all_healthy
121
194
  ? '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.';
195
+ : 'Relayer core services (UI + S3) are healthy. HostIO status pending authentication. Proceed to start_claim.') + degradedSuffix(result);
123
196
 
124
197
  return {
125
198
  content: [{
@@ -4,19 +4,33 @@ const { z } = require('zod');
4
4
  const path = require('path');
5
5
  const { createDockerUtil } = require('../lib/dockerUtil');
6
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.
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
 
17
+ // container_name in the bundled compose. Docker container names are unique
18
+ // per daemon, so ANY existing container with this name — running or stopped,
19
+ // alpha channel or beta — makes `docker compose up` fail with a name conflict.
20
+ const CONTAINER_NAME = 'xns-relayer';
21
+
11
22
  /**
12
23
  * Tool 4: install_relayer
13
24
  * AC-12: confirms "containers starting"; no manual shell.
14
25
  * TP-30: uses execFile, no shell (security non-negotiable).
15
26
  *
16
27
  * 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.
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.
20
34
  *
21
35
  * `compose_url` stays as an optional override for internal/custom installs;
22
36
  * when given, the old download-a-URL behaviour is preserved.
@@ -28,20 +42,40 @@ module.exports = function registerInstallRelayer(server, options = {}) {
28
42
 
29
43
  server.tool(
30
44
  '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.',
45
+ '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.',
32
46
  {
33
47
  install_path: z.string().optional().default('/opt/xns-relayer').describe('Directory to install the compose file into'),
34
48
  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)'),
49
+ s3_port: z.number().int().positive().optional().default(9000).describe('Host port for the S3 API (container 9000)'),
36
50
  compose_url: z.string().url().optional().describe('OPTIONAL override: URL to a custom docker-compose.yml. Omit for the normal released install.'),
37
51
  },
38
- async ({ install_path, ui_port, minio_port, compose_url }) => {
52
+ async ({ install_path, ui_port, s3_port, compose_url }) => {
39
53
  try {
40
54
  const { execFile: nodeExecFile } = require('child_process');
41
55
  const execFileFn = _execFile || nodeExecFile;
42
56
  const composePath = path.join(install_path, 'docker-compose.yml');
43
57
  const envPath = path.join(install_path, '.env');
44
58
 
59
+ // Preflight: install_relayer is for FRESH installs only — it does
60
+ // not upgrade an existing deployment in place. An existing
61
+ // container (running or stopped, any channel) owns the name and
62
+ // would make compose up fail with a confusing name conflict.
63
+ const existing = await docker.findContainer(CONTAINER_NAME);
64
+ if (existing) {
65
+ return {
66
+ content: [{
67
+ type: 'text',
68
+ text: JSON.stringify({
69
+ success: false,
70
+ error: `An existing '${CONTAINER_NAME}' container was found (status: ${existing.status}; image: ${existing.image}). install_relayer performs fresh installs only — it does not upgrade an existing deployment in place.`,
71
+ existing_container: existing,
72
+ remediation: `To replace it with this install: 1) stop and remove the existing container — docker stop ${CONTAINER_NAME} && docker rm ${CONTAINER_NAME} (this does NOT delete its data directory); 2) run install_relayer again. To keep the existing deployment instead, skip install_relayer and continue with check_relayer_health against it.`,
73
+ }, null, 2),
74
+ }],
75
+ isError: true,
76
+ };
77
+ }
78
+
45
79
  // Create install directory (execFile, no shell)
46
80
  await new Promise((resolve, reject) => {
47
81
  execFileFn('mkdir', ['-p', install_path], {}, (err) => {
@@ -50,27 +84,42 @@ module.exports = function registerInstallRelayer(server, options = {}) {
50
84
  });
51
85
  });
52
86
 
87
+ const fetchCompose = (url) => new Promise((resolve, reject) => {
88
+ execFileFn('curl', ['-fsSL', '-o', composePath, url], { timeout: 60000 }, (err) => {
89
+ if (err) return reject(new Error(`Failed to download compose file: ${err.message}`));
90
+ resolve();
91
+ });
92
+ });
93
+
94
+ let source;
95
+ let note;
53
96
  if (compose_url) {
54
97
  // 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
- });
98
+ await fetchCompose(compose_url);
99
+ source = 'compose_url';
61
100
  } 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`);
101
+ // Default path: fetch the canonical channel bundle (relayer +
102
+ // monitoring stack); fall back to the bundled service-parity
103
+ // template only when the fetch fails. Either way, author the
104
+ // .env so the user never writes a file.
105
+ try {
106
+ await fetchCompose(CHANNEL_COMPOSE_URL);
107
+ source = 'channel';
108
+ } catch (fetchErr) {
109
+ const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
110
+ await fsp.writeFile(composePath, template);
111
+ source = 'bundled-fallback';
112
+ note = `Channel bundle fetch failed (${fetchErr.message}) — fell back to the bundled compose. Same services; re-running install later is not required.`;
113
+ }
114
+ await fsp.writeFile(envPath, `UI_PORT=${ui_port}\nS3_PORT=${s3_port}\n`);
66
115
  }
67
116
 
68
- // Run docker compose up -d. cwd + env so ${UI_PORT}/${MINIO_PORT}
117
+ // Run docker compose up -d. cwd + env so ${UI_PORT}/${S3_PORT}
69
118
  // interpolation and the ./data bind resolve in the install dir,
70
119
  // regardless of where the MCP process was launched.
71
120
  await docker.composeUp(composePath, {
72
121
  cwd: install_path,
73
- env: { ...process.env, UI_PORT: String(ui_port), MINIO_PORT: String(minio_port) },
122
+ env: { ...process.env, UI_PORT: String(ui_port), S3_PORT: String(s3_port) },
74
123
  });
75
124
 
76
125
  return {
@@ -81,7 +130,8 @@ module.exports = function registerInstallRelayer(server, options = {}) {
81
130
  message: 'XNS Relayer containers are starting. Use check_relayer_health to monitor when all services are ready.',
82
131
  compose_path: composePath,
83
132
  install_path,
84
- source: compose_url ? 'compose_url' : 'bundled',
133
+ source,
134
+ ...(note ? { note } : {}),
85
135
  }, null, 2),
86
136
  }],
87
137
  };
@@ -2,25 +2,29 @@
2
2
 
3
3
  const { z } = require('zod');
4
4
  const { createS3Client } = require('../lib/s3Client');
5
+ const { createDockerUtil } = require('../lib/dockerUtil');
5
6
 
6
7
  /**
7
8
  * Tool 10: verify_storage
8
- * AC-20: targets localhost:9000 plain HTTP; reports endpoint.
9
+ * AC-20: targets the S3 gateway on port 9000 plain HTTP; reports endpoint.
9
10
  * AC-21: verify fail → names the failing step (CreateBucket/PutObject/GetObject).
10
11
  * R6: S3 credentials must be provided (from relayer-ui IAM key-mgmt flow).
11
12
  *
12
13
  * Performs a round-trip verification: CreateBucket → PutObject → GetObject → compare.
14
+ * Remote Docker: when endpoint is omitted, it defaults to port 9000 on the
15
+ * machine the Docker daemon runs on (auto-detected), not blindly localhost.
13
16
  */
14
17
  module.exports = function registerVerifyStorage(server, options = {}) {
15
18
  const _createS3Client = options.createS3Client || createS3Client;
19
+ const docker = options.dockerUtil || createDockerUtil(options);
16
20
 
17
21
  server.tool(
18
22
  '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.',
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. You must provide S3 access credentials — these can be found in the Relayer configuration or generated via the Relayer UI.',
20
24
  {
21
25
  access_key_id: z.string().describe('S3 access key ID'),
22
26
  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)'),
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.'),
24
28
  },
25
29
  async ({ access_key_id, secret_access_key, endpoint }) => {
26
30
  const testBucket = `mcp-verify-${Date.now()}`;
@@ -29,6 +33,10 @@ module.exports = function registerVerifyStorage(server, options = {}) {
29
33
  let currentStep = 'init';
30
34
 
31
35
  try {
36
+ if (!endpoint) {
37
+ const { host } = await docker.getDockerHost();
38
+ endpoint = `http://${host}:9000`;
39
+ }
32
40
  const s3 = _createS3Client({
33
41
  endpoint,
34
42
  accessKeyId: access_key_id,