@xns-cloud/relayer-mcp 0.3.0 → 0.4.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 +69 -7
- package/package.json +3 -2
- package/src/index.js +10 -1
- package/src/lib/dockerUtil.js +83 -2
- package/src/lib/nodeVersion.js +34 -0
- package/src/templates/docker-compose.yml +16 -10
- package/src/tools/checkPrerequisites.js +52 -7
- package/src/tools/checkRelayerHealth.js +55 -9
- package/src/tools/installRelayer.js +35 -8
- package/src/tools/verifyStorage.js +11 -3
package/README.md
CHANGED
|
@@ -1,10 +1,30 @@
|
|
|
1
1
|
# @xns-cloud/relayer-mcp
|
|
2
2
|
|
|
3
|
-
MCP server for XNS Relayer onboarding. Provides
|
|
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
|
-
##
|
|
5
|
+
## Requirements
|
|
6
6
|
|
|
7
|
-
|
|
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` (
|
|
30
|
-
| 5 | `check_relayer_health` | Poll UI, S3, and HostIO health (10s interval, 300s timeout). |
|
|
49
|
+
| 4 | `install_relayer` | Write the bundled `docker-compose.yml` + `.env` (`releases.scpri.me/xns-relayer:beta-latest` — the pre-release beta channel, anonymous pull, no `docker login`) and start the containers. **Fresh installs only** — see [Fresh installs vs. existing deployments](#fresh-installs-vs-existing-deployments). The user authors nothing; `compose_url` is an optional override (e.g. the full channel bundle with monitoring: `https://releases.scpri.me/relayer/beta/docker-compose.yml`). |
|
|
50
|
+
| 5 | `check_relayer_health` | Poll UI, S3, and HostIO health (10s interval, 300s timeout). 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)
|
|
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
|
+
"version": "0.4.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",
|
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: '
|
|
18
|
+
version: require('../package.json').version,
|
|
10
19
|
});
|
|
11
20
|
|
|
12
21
|
// Register all 11 tools
|
package/src/lib/dockerUtil.js
CHANGED
|
@@ -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
|
-
|
|
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,33 @@
|
|
|
1
|
-
# Bundled by @xns-cloud/relayer-mcp — the
|
|
2
|
-
# Source of truth:
|
|
1
|
+
# Bundled by @xns-cloud/relayer-mcp — the released XNS Relayer install.
|
|
2
|
+
# Source of truth: the beta release channel on the XNS releases registry
|
|
3
|
+
# (releases.scpri.me — anonymous pull, no docker login). The full channel bundle
|
|
4
|
+
# (relayer + monitoring stack) lives at
|
|
5
|
+
# https://releases.scpri.me/relayer/beta/docker-compose.yml; this bundled file is
|
|
6
|
+
# the minimal relayer-only install. NEVER point this at Docker Hub scprime/* —
|
|
7
|
+
# those are production fleet tags and are not a release channel.
|
|
3
8
|
#
|
|
4
|
-
# Pre-release: pinned to
|
|
5
|
-
#
|
|
9
|
+
# Pre-release: pinned to :beta-latest so beta testers exercise the current
|
|
10
|
+
# Relayer agentically. Flip this one tag to :stable-latest at general release.
|
|
6
11
|
#
|
|
7
12
|
# A first-time user no longer hand-writes this file or the .env — install_relayer
|
|
8
|
-
# writes both. Differences from the
|
|
9
|
-
# - data lives in ./data (relative to this file) instead of a
|
|
10
|
-
#
|
|
13
|
+
# writes both. Differences from the channel compose, by design:
|
|
14
|
+
# - data lives in ./data (relative to this file) instead of a named volume,
|
|
15
|
+
# so the same file works on Windows, macOS, and Linux and the data is easy
|
|
16
|
+
# to find next to the compose.
|
|
11
17
|
# - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
|
|
12
18
|
# 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
|
|
19
|
+
# - pull_policy: always so `docker compose up -d` fetches the current beta
|
|
14
20
|
# image without a separate `docker compose pull` step.
|
|
15
21
|
services:
|
|
16
22
|
xns:
|
|
17
23
|
container_name: xns-relayer
|
|
18
|
-
image:
|
|
24
|
+
image: releases.scpri.me/xns-relayer:beta-latest
|
|
19
25
|
pull_policy: always
|
|
20
26
|
restart: unless-stopped
|
|
21
27
|
volumes:
|
|
22
28
|
- ./data:/relayer
|
|
23
29
|
ports:
|
|
24
30
|
- ${UI_PORT:-8888}:8888
|
|
25
|
-
- ${
|
|
31
|
+
- ${S3_PORT:-9000}:9000
|
|
26
32
|
env_file:
|
|
27
33
|
- .env
|
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
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), and HostIO. Polls every 10 seconds for up to 300 seconds. Reports each component status individually and names any unhealthy component. 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
|
|
77
|
+
// UI health
|
|
34
78
|
try {
|
|
35
|
-
const { status } = await http.get(
|
|
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
|
|
85
|
+
// S3 gateway
|
|
42
86
|
try {
|
|
43
|
-
const { status } = await http.get(
|
|
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(
|
|
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' };
|
|
@@ -65,14 +109,16 @@ module.exports = function registerCheckRelayerHealth(server, options = {}) {
|
|
|
65
109
|
const allKnownHealthy = coreHealthy && components.hostio.healthy === true;
|
|
66
110
|
|
|
67
111
|
const unhealthy = [];
|
|
68
|
-
if (!components.ui.healthy) unhealthy.push(
|
|
69
|
-
if (!components.s3.healthy) unhealthy.push(
|
|
112
|
+
if (!components.ui.healthy) unhealthy.push(`UI (${target}:${uiPort})`);
|
|
113
|
+
if (!components.s3.healthy) unhealthy.push(`S3 gateway (${target}:${s3Port})`);
|
|
70
114
|
|
|
71
115
|
return {
|
|
72
116
|
components,
|
|
73
117
|
healthy: coreHealthy,
|
|
74
118
|
all_healthy: allKnownHealthy,
|
|
75
119
|
unhealthy: unhealthy.length > 0 ? unhealthy : undefined,
|
|
120
|
+
target_host: target,
|
|
121
|
+
remote_docker: dockerHost.remote === true || undefined,
|
|
76
122
|
};
|
|
77
123
|
};
|
|
78
124
|
|
|
@@ -8,6 +8,11 @@ const { createDockerUtil } = require('../lib/dockerUtil');
|
|
|
8
8
|
// `files: ["src/"]` covers it). This is the documented xns.tech install.
|
|
9
9
|
const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose.yml');
|
|
10
10
|
|
|
11
|
+
// container_name in the bundled compose. Docker container names are unique
|
|
12
|
+
// per daemon, so ANY existing container with this name — running or stopped,
|
|
13
|
+
// alpha channel or beta — makes `docker compose up` fail with a name conflict.
|
|
14
|
+
const CONTAINER_NAME = 'xns-relayer';
|
|
15
|
+
|
|
11
16
|
/**
|
|
12
17
|
* Tool 4: install_relayer
|
|
13
18
|
* AC-12: confirms "containers starting"; no manual shell.
|
|
@@ -15,8 +20,10 @@ const TEMPLATE_PATH = path.join(__dirname, '..', 'templates', 'docker-compose.ym
|
|
|
15
20
|
*
|
|
16
21
|
* A first-time user has never heard of a Relayer and cannot supply a compose
|
|
17
22
|
* file or a .env. So by default this tool WRITES both for them — the bundled
|
|
18
|
-
* released compose (
|
|
19
|
-
*
|
|
23
|
+
* released compose (releases.scpri.me/xns-relayer:beta-latest, the pre-release
|
|
24
|
+
* beta channel; anonymous pull, no docker login) plus a .env carrying the two
|
|
25
|
+
* ports. The full channel bundle (relayer + monitoring stack) is published at
|
|
26
|
+
* https://releases.scpri.me/relayer/beta/docker-compose.yml for compose_url.
|
|
20
27
|
*
|
|
21
28
|
* `compose_url` stays as an optional override for internal/custom installs;
|
|
22
29
|
* when given, the old download-a-URL behaviour is preserved.
|
|
@@ -28,20 +35,40 @@ module.exports = function registerInstallRelayer(server, options = {}) {
|
|
|
28
35
|
|
|
29
36
|
server.tool(
|
|
30
37
|
'install_relayer',
|
|
31
|
-
'Install and start the XNS Relayer. By default writes the bundled docker-compose.yml (
|
|
38
|
+
'Install and start the XNS Relayer. By default writes the bundled docker-compose.yml (releases.scpri.me/xns-relayer:beta-latest — the pre-release beta channel, anonymous pull) 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 (e.g. the full channel bundle with monitoring at https://releases.scpri.me/relayer/beta/docker-compose.yml).',
|
|
32
39
|
{
|
|
33
40
|
install_path: z.string().optional().default('/opt/xns-relayer').describe('Directory to install the compose file into'),
|
|
34
41
|
ui_port: z.number().int().positive().optional().default(8888).describe('Host port for the Relayer admin/customer UI (container 8888)'),
|
|
35
|
-
|
|
42
|
+
s3_port: z.number().int().positive().optional().default(9000).describe('Host port for the S3 API (container 9000)'),
|
|
36
43
|
compose_url: z.string().url().optional().describe('OPTIONAL override: URL to a custom docker-compose.yml. Omit for the normal released install.'),
|
|
37
44
|
},
|
|
38
|
-
async ({ install_path, ui_port,
|
|
45
|
+
async ({ install_path, ui_port, s3_port, compose_url }) => {
|
|
39
46
|
try {
|
|
40
47
|
const { execFile: nodeExecFile } = require('child_process');
|
|
41
48
|
const execFileFn = _execFile || nodeExecFile;
|
|
42
49
|
const composePath = path.join(install_path, 'docker-compose.yml');
|
|
43
50
|
const envPath = path.join(install_path, '.env');
|
|
44
51
|
|
|
52
|
+
// Preflight: install_relayer is for FRESH installs only — it does
|
|
53
|
+
// not upgrade an existing deployment in place. An existing
|
|
54
|
+
// container (running or stopped, any channel) owns the name and
|
|
55
|
+
// would make compose up fail with a confusing name conflict.
|
|
56
|
+
const existing = await docker.findContainer(CONTAINER_NAME);
|
|
57
|
+
if (existing) {
|
|
58
|
+
return {
|
|
59
|
+
content: [{
|
|
60
|
+
type: 'text',
|
|
61
|
+
text: JSON.stringify({
|
|
62
|
+
success: false,
|
|
63
|
+
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.`,
|
|
64
|
+
existing_container: existing,
|
|
65
|
+
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.`,
|
|
66
|
+
}, null, 2),
|
|
67
|
+
}],
|
|
68
|
+
isError: true,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
45
72
|
// Create install directory (execFile, no shell)
|
|
46
73
|
await new Promise((resolve, reject) => {
|
|
47
74
|
execFileFn('mkdir', ['-p', install_path], {}, (err) => {
|
|
@@ -62,15 +89,15 @@ module.exports = function registerInstallRelayer(server, options = {}) {
|
|
|
62
89
|
// Default path: write the bundled released compose + .env for the user.
|
|
63
90
|
const template = await fsp.readFile(TEMPLATE_PATH, 'utf8');
|
|
64
91
|
await fsp.writeFile(composePath, template);
|
|
65
|
-
await fsp.writeFile(envPath, `UI_PORT=${ui_port}\
|
|
92
|
+
await fsp.writeFile(envPath, `UI_PORT=${ui_port}\nS3_PORT=${s3_port}\n`);
|
|
66
93
|
}
|
|
67
94
|
|
|
68
|
-
// Run docker compose up -d. cwd + env so ${UI_PORT}/${
|
|
95
|
+
// Run docker compose up -d. cwd + env so ${UI_PORT}/${S3_PORT}
|
|
69
96
|
// interpolation and the ./data bind resolve in the install dir,
|
|
70
97
|
// regardless of where the MCP process was launched.
|
|
71
98
|
await docker.composeUp(composePath, {
|
|
72
99
|
cwd: install_path,
|
|
73
|
-
env: { ...process.env, UI_PORT: String(ui_port),
|
|
100
|
+
env: { ...process.env, UI_PORT: String(ui_port), S3_PORT: String(s3_port) },
|
|
74
101
|
});
|
|
75
102
|
|
|
76
103
|
return {
|
|
@@ -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
|
|
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.
|
|
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().
|
|
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,
|