@xns-cloud/relayer-mcp 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,256 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const http = require('http');
5
+ const { createHttpClient } = require('./httpClient');
6
+
7
+ const DEFAULT_KEYCLOAK_URL = 'https://auth.xns.tech/auth';
8
+ const DEFAULT_REALM = 'scprime';
9
+ const DEFAULT_CLIENT_ID = 'relayer-native';
10
+
11
+ /**
12
+ * OIDC Authorization Code + PKCE (S256) token acquisition.
13
+ *
14
+ * Performs a browser-based OIDC sign-in flow:
15
+ * 1. Generate PKCE verifier + S256 challenge
16
+ * 2. Start a 127.0.0.1 loopback HTTP listener on an ephemeral port
17
+ * 3. Build the authorize URL and open the browser (or return the URL)
18
+ * 4. Capture the authorization code on the redirect
19
+ * 5. Exchange code for access_token + refresh_token
20
+ *
21
+ * Interface is kept clean for future extraction to @xns-cloud/relayer-auth (CLI reuse).
22
+ *
23
+ * @param {object} opts
24
+ * @param {string} [opts.clientId] - Keycloak public client ID
25
+ * @param {string} [opts.realm] - Keycloak realm
26
+ * @param {string} [opts.keycloakUrl] - Base Keycloak URL
27
+ * @param {string[]} [opts.scopes] - OIDC scopes
28
+ * @param {object} [opts.httpClient] - Injected HTTP client (testing)
29
+ * @param {function} [opts.openBrowser] - Injected browser opener (testing)
30
+ * @param {function} [opts.createServer] - Injected http.createServer (testing)
31
+ * @returns {Promise<{access_token: string, expires_at: number, refresh_token: string, authorize_url: string}>}
32
+ */
33
+ async function acquireToken(opts = {}) {
34
+ const clientId = opts.clientId || DEFAULT_CLIENT_ID;
35
+ const realm = opts.realm || DEFAULT_REALM;
36
+ const keycloakUrl = opts.keycloakUrl || DEFAULT_KEYCLOAK_URL;
37
+ const scopes = opts.scopes || ['openid'];
38
+ const httpClient = opts.httpClient || createHttpClient({ timeout: 15000 });
39
+ const openBrowser = opts.openBrowser || defaultOpenBrowser;
40
+ const _createServer = opts.createServer || http.createServer.bind(http);
41
+
42
+ // 1. PKCE verifier + S256 challenge
43
+ const verifier = generateVerifier();
44
+ const challenge = generateChallenge(verifier);
45
+
46
+ // 2. Loopback listener on ephemeral port (R7: platform-agnostic bind)
47
+ const { code, redirectUri } = await captureAuthCode({
48
+ clientId,
49
+ realm,
50
+ keycloakUrl,
51
+ scopes,
52
+ verifier,
53
+ challenge,
54
+ openBrowser,
55
+ createServer: _createServer,
56
+ });
57
+
58
+ // 5. Exchange code for tokens
59
+ const tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
60
+ const body = new URLSearchParams({
61
+ grant_type: 'authorization_code',
62
+ client_id: clientId,
63
+ code,
64
+ redirect_uri: redirectUri,
65
+ code_verifier: verifier,
66
+ }).toString();
67
+
68
+ const { status, data } = await httpClient.post(tokenUrl, body, {
69
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
70
+ });
71
+
72
+ if (status !== 200) {
73
+ throw new Error(`Token exchange failed (HTTP ${status}): ${JSON.stringify(data)}`);
74
+ }
75
+
76
+ return {
77
+ access_token: data.access_token,
78
+ refresh_token: data.refresh_token,
79
+ expires_at: Date.now() + (data.expires_in * 1000),
80
+ authorize_url: undefined, // already consumed
81
+ };
82
+ }
83
+
84
+ /**
85
+ * Refresh an access token using a refresh_token.
86
+ *
87
+ * @param {object} opts
88
+ * @param {string} opts.refreshToken
89
+ * @param {string} [opts.clientId]
90
+ * @param {string} [opts.realm]
91
+ * @param {string} [opts.keycloakUrl]
92
+ * @param {object} [opts.httpClient]
93
+ * @returns {Promise<{access_token: string, expires_at: number, refresh_token: string}>}
94
+ */
95
+ async function refreshToken(opts = {}) {
96
+ const clientId = opts.clientId || DEFAULT_CLIENT_ID;
97
+ const realm = opts.realm || DEFAULT_REALM;
98
+ const keycloakUrl = opts.keycloakUrl || DEFAULT_KEYCLOAK_URL;
99
+ const httpClient = opts.httpClient || createHttpClient({ timeout: 15000 });
100
+
101
+ const tokenUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/token`;
102
+ const body = new URLSearchParams({
103
+ grant_type: 'refresh_token',
104
+ client_id: clientId,
105
+ refresh_token: opts.refreshToken,
106
+ }).toString();
107
+
108
+ const { status, data } = await httpClient.post(tokenUrl, body, {
109
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
110
+ });
111
+
112
+ if (status !== 200) {
113
+ throw new Error(`Token refresh failed (HTTP ${status}): ${JSON.stringify(data)}`);
114
+ }
115
+
116
+ return {
117
+ access_token: data.access_token,
118
+ refresh_token: data.refresh_token,
119
+ expires_at: Date.now() + (data.expires_in * 1000),
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Generate a PKCE code verifier (43-128 chars, URL-safe base64).
125
+ * @returns {string}
126
+ */
127
+ function generateVerifier() {
128
+ return crypto.randomBytes(32).toString('base64url');
129
+ }
130
+
131
+ /**
132
+ * Generate a PKCE S256 challenge from a verifier.
133
+ * @param {string} verifier
134
+ * @returns {string}
135
+ */
136
+ function generateChallenge(verifier) {
137
+ return crypto.createHash('sha256').update(verifier).digest('base64url');
138
+ }
139
+
140
+ /**
141
+ * Start a loopback HTTP server, open the browser to the authorize URL,
142
+ * and wait for the authorization code redirect.
143
+ *
144
+ * @param {object} opts
145
+ * @returns {Promise<{code: string, redirectUri: string}>}
146
+ */
147
+ function captureAuthCode(opts) {
148
+ const { clientId, realm, keycloakUrl, scopes, verifier, challenge, openBrowser, createServer } = opts;
149
+
150
+ return new Promise((resolve, reject) => {
151
+ let timeoutHandle;
152
+ let capturedPort;
153
+ let expectedState;
154
+
155
+ const srv = createServer((req, res) => {
156
+ const url = new URL(req.url, `http://127.0.0.1`);
157
+
158
+ const error = url.searchParams.get('error');
159
+ if (error) {
160
+ res.writeHead(400, { 'Content-Type': 'text/html' });
161
+ res.end('<html><body><h1>Authentication failed</h1><p>You can close this window.</p></body></html>');
162
+ clearTimeout(timeoutHandle);
163
+ srv.close();
164
+ return reject(new Error(`OIDC error: ${error} — ${url.searchParams.get('error_description') || ''}`));
165
+ }
166
+
167
+ const code = url.searchParams.get('code');
168
+ if (!code) {
169
+ res.writeHead(400, { 'Content-Type': 'text/html' });
170
+ res.end('<html><body><h1>Missing authorization code</h1></body></html>');
171
+ return; // Don't close server — wait for the real redirect
172
+ }
173
+
174
+ // R5-STATE-1: Validate OIDC state parameter to prevent CSRF
175
+ const returnedState = url.searchParams.get('state');
176
+ if (returnedState !== expectedState) {
177
+ res.writeHead(400, { 'Content-Type': 'text/html' });
178
+ res.end('<html><body><h1>Invalid state parameter</h1><p>Possible CSRF attack. Please try again.</p></body></html>');
179
+ clearTimeout(timeoutHandle);
180
+ srv.close();
181
+ return reject(new Error('OIDC callback state mismatch — possible CSRF. Please try again.'));
182
+ }
183
+
184
+ res.writeHead(200, { 'Content-Type': 'text/html' });
185
+ res.end('<html><body><h1>Authentication successful</h1><p>You can close this window and return to the agent.</p></body></html>');
186
+ clearTimeout(timeoutHandle);
187
+
188
+ // Capture redirect URI from the port stored in listen callback
189
+ // BEFORE calling srv.close() — after close, srv.address() may return null
190
+ const redirectUri = `http://127.0.0.1:${capturedPort}/callback`;
191
+ srv.close();
192
+ resolve({ code, redirectUri });
193
+ });
194
+
195
+ // Bind to 127.0.0.1:0 (ephemeral port) — R7 platform-agnostic
196
+ srv.listen(0, '127.0.0.1', () => {
197
+ capturedPort = srv.address().port;
198
+ const redirectUri = `http://127.0.0.1:${capturedPort}/callback`;
199
+ expectedState = crypto.randomBytes(16).toString('hex');
200
+
201
+ const params = new URLSearchParams({
202
+ response_type: 'code',
203
+ client_id: clientId,
204
+ redirect_uri: redirectUri,
205
+ scope: scopes.join(' '),
206
+ code_challenge: challenge,
207
+ code_challenge_method: 'S256',
208
+ state: expectedState,
209
+ });
210
+
211
+ const authorizeUrl = `${keycloakUrl}/realms/${realm}/protocol/openid-connect/auth?${params}`;
212
+
213
+ // Open browser or return URL for the agent to present
214
+ openBrowser(authorizeUrl).catch(() => {
215
+ // If browser open fails, the authorize_url is still available
216
+ });
217
+ });
218
+
219
+ // Timeout: 5 minutes for user to complete browser sign-in
220
+ timeoutHandle = setTimeout(() => {
221
+ srv.close();
222
+ reject(new Error('OIDC sign-in timed out after 5 minutes. Please try again.'));
223
+ }, 300000);
224
+ });
225
+ }
226
+
227
+ /**
228
+ * Default browser opener — platform-aware.
229
+ * Uses execFile (not exec) to avoid shell interpolation of the URL.
230
+ * @param {string} url
231
+ * @returns {Promise<void>}
232
+ */
233
+ function defaultOpenBrowser(url) {
234
+ const { execFile } = require('child_process');
235
+ return new Promise((resolve, reject) => {
236
+ const platform = process.platform;
237
+ let cmd, args;
238
+ if (platform === 'darwin') {
239
+ cmd = 'open';
240
+ args = [url];
241
+ } else if (platform === 'win32') {
242
+ cmd = 'cmd';
243
+ args = ['/c', 'start', '', url];
244
+ } else {
245
+ cmd = 'xdg-open';
246
+ args = [url];
247
+ }
248
+
249
+ execFile(cmd, args, (err) => {
250
+ if (err) return reject(err);
251
+ resolve();
252
+ });
253
+ });
254
+ }
255
+
256
+ module.exports = { acquireToken, refreshToken, generateVerifier, generateChallenge };
@@ -0,0 +1,31 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Poll a check function at a fixed interval until it resolves truthy
5
+ * or the timeout elapses.
6
+ *
7
+ * @param {object} opts
8
+ * @param {function(): Promise<*>} opts.fn - Async check function. Return truthy to stop.
9
+ * @param {number} opts.intervalMs - Poll interval in ms
10
+ * @param {number} opts.timeoutMs - Total timeout in ms
11
+ * @param {function} [opts.sleep] - Injectable sleep (testing). Default: real setTimeout.
12
+ * @returns {Promise<{result: *, timedOut: boolean}>}
13
+ */
14
+ async function pollUntil({ fn, intervalMs, timeoutMs, sleep }) {
15
+ const _sleep = sleep || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
16
+ const deadline = Date.now() + timeoutMs;
17
+
18
+ while (Date.now() < deadline) {
19
+ const result = await fn();
20
+ if (result) {
21
+ return { result, timedOut: false };
22
+ }
23
+ const remaining = deadline - Date.now();
24
+ if (remaining <= 0) break;
25
+ await _sleep(Math.min(intervalMs, remaining));
26
+ }
27
+
28
+ return { result: null, timedOut: true };
29
+ }
30
+
31
+ module.exports = { pollUntil };
@@ -0,0 +1,73 @@
1
+ 'use strict';
2
+
3
+ const {
4
+ S3Client,
5
+ CreateBucketCommand,
6
+ PutObjectCommand,
7
+ GetObjectCommand,
8
+ } = require('@aws-sdk/client-s3');
9
+
10
+ /**
11
+ * S3 client for verify_storage (Tool 10).
12
+ * Targets localhost:9000 plain HTTP with forcePathStyle.
13
+ *
14
+ * @param {object} [options]
15
+ * @param {string} [options.endpoint] - S3 endpoint (default: http://localhost:9000)
16
+ * @param {string} [options.region] - AWS region (default: us-east-1)
17
+ * @param {string} options.accessKeyId
18
+ * @param {string} options.secretAccessKey
19
+ * @param {object} [options.s3Client] - Injected S3Client instance (testing)
20
+ */
21
+ function createS3Client(options = {}) {
22
+ const client = options.s3Client || new S3Client({
23
+ endpoint: options.endpoint || 'http://localhost:9000',
24
+ region: options.region || 'us-east-1',
25
+ forcePathStyle: true,
26
+ credentials: {
27
+ accessKeyId: options.accessKeyId,
28
+ secretAccessKey: options.secretAccessKey,
29
+ },
30
+ });
31
+
32
+ return {
33
+ /**
34
+ * @param {string} bucket
35
+ * @returns {Promise<void>}
36
+ */
37
+ async createBucket(bucket) {
38
+ await client.send(new CreateBucketCommand({ Bucket: bucket }));
39
+ },
40
+
41
+ /**
42
+ * @param {string} bucket
43
+ * @param {string} key
44
+ * @param {Buffer|string} body
45
+ * @returns {Promise<void>}
46
+ */
47
+ async putObject(bucket, key, body) {
48
+ await client.send(new PutObjectCommand({
49
+ Bucket: bucket,
50
+ Key: key,
51
+ Body: body,
52
+ }));
53
+ },
54
+
55
+ /**
56
+ * @param {string} bucket
57
+ * @param {string} key
58
+ * @returns {Promise<string>}
59
+ */
60
+ async getObject(bucket, key) {
61
+ const resp = await client.send(new GetObjectCommand({
62
+ Bucket: bucket,
63
+ Key: key,
64
+ }));
65
+ return resp.Body.transformToString();
66
+ },
67
+
68
+ /** Expose the raw client for advanced use */
69
+ client,
70
+ };
71
+ }
72
+
73
+ module.exports = { createS3Client };
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Shared OIDC token state — single module-level cache shared by
5
+ * getHostTags and configureVpd (and any future authenticated tools).
6
+ *
7
+ * Eliminates duplicate browser sign-ins caused by independent
8
+ * `let tokenState = null` declarations in each tool module.
9
+ */
10
+ let tokenState = null;
11
+
12
+ function get() {
13
+ return tokenState;
14
+ }
15
+
16
+ function set(state) {
17
+ tokenState = state;
18
+ }
19
+
20
+ function clear() {
21
+ tokenState = null;
22
+ }
23
+
24
+ module.exports = { get, set, clear };
@@ -0,0 +1,27 @@
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).
3
+ #
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.
6
+ #
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.
11
+ # - SMB ports 139/445 are intentionally NOT exposed: 445 is held by Windows
12
+ # 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.
15
+ services:
16
+ xns:
17
+ container_name: xns-relayer
18
+ image: scprime/xns-relayer:beta
19
+ pull_policy: always
20
+ restart: unless-stopped
21
+ volumes:
22
+ - ./data:/relayer
23
+ ports:
24
+ - ${UI_PORT:-8888}:8888
25
+ - ${MINIO_PORT:-9000}:9000
26
+ env_file:
27
+ - .env
@@ -0,0 +1,154 @@
1
+ 'use strict';
2
+
3
+ const { z } = require('zod');
4
+ const { createHttpClient } = require('../lib/httpClient');
5
+ const { pollUntil } = require('../lib/pollUntil');
6
+
7
+ const RELAYER_UI_BASE = 'http://localhost:8888';
8
+ const POLL_INTERVAL_MS = 10000; // 10s (AC-15)
9
+
10
+ /**
11
+ * Tool 7: check_claim_status
12
+ * AC-15: poll every 10s; surface STATE_1->2->3; auto-proceed on STATE_3.
13
+ * AC-15a: 402 → {continue_polling:false, message}, no TTL loop.
14
+ * AC-16: TTL expiry (no 402) → "session expired" + new start_claim.
15
+ *
16
+ * Consumes relayer-ui: GET /api/v1/claim/status?claim_id=...
17
+ * → {state:'STATE_1'|'STATE_2'|'STATE_3', detail, installation_id?, claimed_at?}
18
+ * → 402: payment method required
19
+ * → 404: claim not found (expired or invalid)
20
+ */
21
+ module.exports = function registerCheckClaimStatus(server, options = {}) {
22
+ const http = options.httpClient || createHttpClient(options);
23
+ const relayerUiBase = options.relayerUiBase || RELAYER_UI_BASE;
24
+ const pollInterval = options.pollIntervalMs ?? POLL_INTERVAL_MS;
25
+ const sleep = options.sleep;
26
+
27
+ server.tool(
28
+ 'check_claim_status',
29
+ 'Poll the status of a claim session. Checks every 10 seconds. States: STATE_1 (pending — user has not yet opened the claim URL), STATE_2 (in progress — user is completing the claim in browser), STATE_3 (completed — claim successful). Automatically proceeds when STATE_3 is reached.',
30
+ {
31
+ claim_id: z.string().describe('The claim_id returned by start_claim'),
32
+ timeout_ms: z.number().optional().default(600000).describe('Maximum time to poll in milliseconds (default: 10 minutes)'),
33
+ },
34
+ async ({ claim_id, timeout_ms }) => {
35
+ try {
36
+ const checkOnce = async () => {
37
+ const { status, data } = await http.get(
38
+ `${relayerUiBase}/api/v1/claim/status?claim_id=${encodeURIComponent(claim_id)}`,
39
+ );
40
+
41
+ // AC-15a: 402 → stop, no loop
42
+ if (status === 402) {
43
+ return {
44
+ done: true,
45
+ stop: true,
46
+ result: {
47
+ success: false,
48
+ continue_polling: false,
49
+ message: 'A payment method is required before the claim can complete. Please add a payment method at console.xns.tech, then run start_claim again.',
50
+ },
51
+ };
52
+ }
53
+
54
+ // AC-16: 404 → expired
55
+ if (status === 404) {
56
+ return {
57
+ done: true,
58
+ result: {
59
+ success: false,
60
+ message: 'Claim session has expired or was not found. Run start_claim to create a new claim session.',
61
+ expired: true,
62
+ },
63
+ };
64
+ }
65
+
66
+ if (status >= 400) {
67
+ return {
68
+ done: true,
69
+ result: {
70
+ success: false,
71
+ error: data.error || `Claim status check failed (HTTP ${status}).`,
72
+ },
73
+ };
74
+ }
75
+
76
+ const state = data.state;
77
+
78
+ if (state === 'STATE_3') {
79
+ return {
80
+ done: true,
81
+ result: {
82
+ success: true,
83
+ message: 'Claim completed successfully. The Relayer is now linked to your account.',
84
+ state: 'STATE_3',
85
+ installation_id: data.installation_id,
86
+ claimed_at: data.claimed_at,
87
+ },
88
+ };
89
+ }
90
+
91
+ // STATE_1 or STATE_2 — still in progress
92
+ const stateMessages = {
93
+ STATE_1: 'Waiting for the user to open the claim URL in a browser.',
94
+ STATE_2: 'Claim is in progress — the user is completing the browser flow.',
95
+ };
96
+
97
+ return {
98
+ done: false,
99
+ result: {
100
+ success: false,
101
+ state,
102
+ message: stateMessages[state] || `Claim in state: ${state}`,
103
+ detail: data.detail,
104
+ },
105
+ };
106
+ };
107
+
108
+ const { result, timedOut } = await pollUntil({
109
+ fn: async () => {
110
+ const check = await checkOnce();
111
+ if (check.done) return check.result;
112
+ return null;
113
+ },
114
+ intervalMs: pollInterval,
115
+ timeoutMs: timeout_ms,
116
+ sleep,
117
+ });
118
+
119
+ if (timedOut) {
120
+ // AC-16: TTL expiry
121
+ return {
122
+ content: [{
123
+ type: 'text',
124
+ text: JSON.stringify({
125
+ success: false,
126
+ message: 'Claim status polling timed out. The claim session may have expired. Run start_claim to create a new claim session.',
127
+ expired: true,
128
+ }, null, 2),
129
+ }],
130
+ };
131
+ }
132
+
133
+ return {
134
+ content: [{
135
+ type: 'text',
136
+ text: JSON.stringify(result, null, 2),
137
+ }],
138
+ isError: (result.success === false && result.continue_polling !== false) ? true : undefined,
139
+ };
140
+ } catch (err) {
141
+ return {
142
+ content: [{
143
+ type: 'text',
144
+ text: JSON.stringify({
145
+ success: false,
146
+ error: `Claim status check failed: ${err.message}`,
147
+ }),
148
+ }],
149
+ isError: true,
150
+ };
151
+ }
152
+ },
153
+ );
154
+ };