@relay-org/relay-mcp 0.1.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.
Files changed (3) hide show
  1. package/README.md +78 -0
  2. package/index.js +450 -0
  3. package/package.json +42 -0
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # relay-mcp
2
+
3
+ Model Context Protocol server for [Relay](https://github.com/Relay-CI/Relay). Lets any MCP-aware AI tool (Cursor, Claude Desktop, VS Code Copilot, etc.) inspect and control a Relay agent directly.
4
+
5
+ ## Setup
6
+
7
+ Add to your MCP config (`~/.cursor/mcp.json`, Claude Desktop config, etc.):
8
+
9
+ ```json
10
+ {
11
+ "mcpServers": {
12
+ "relay": {
13
+ "command": "npx",
14
+ "args": ["-y", "@relay-org/relay-mcp"],
15
+ "env": {
16
+ "RELAY_URL": "http://your-server:8080",
17
+ "RELAY_TOKEN": "your-token"
18
+ }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ For a local agent on the same machine, use a Unix socket instead (no token needed):
25
+
26
+ ```json
27
+ {
28
+ "mcpServers": {
29
+ "relay": {
30
+ "command": "npx",
31
+ "args": ["-y", "@relay-org/relay-mcp"],
32
+ "env": {
33
+ "RELAY_SOCKET": "/path/to/relay.sock"
34
+ }
35
+ }
36
+ }
37
+ }
38
+ ```
39
+
40
+ ## Environment variables
41
+
42
+ | Variable | Description |
43
+ |---|---|
44
+ | `RELAY_SOCKET` | Path to `relay.sock`. Takes priority over HTTP if set. |
45
+ | `RELAY_URL` | Agent base URL. Default: `http://127.0.0.1:8080` |
46
+ | `RELAY_TOKEN` | Bearer token for HTTP transport. |
47
+
48
+ ## Tools
49
+
50
+ | Tool | Description |
51
+ |---|---|
52
+ | `list_projects` | All projects and environments |
53
+ | `list_deploys` | Recent deploys, filterable by app/env/branch |
54
+ | `get_deploy` | Single deploy record by ID |
55
+ | `get_deploy_logs` | Build/deploy logs for a deploy ID |
56
+ | `cancel_deploy` | Cancel an in-progress deploy |
57
+ | `rollback` | Roll back to the previous image |
58
+ | `start_app` | Start a stopped container |
59
+ | `stop_app` | Stop a running container |
60
+ | `restart_app` | Restart a container without a new build |
61
+ | `delete_lane` | Remove a lane and its state |
62
+ | `get_app_config` | Lane config (access policy, hosts, engine) |
63
+ | `set_app_config` | Update lane config |
64
+ | `list_secrets` | Secret key names for an app lane |
65
+ | `add_secret` | Add or update a secret |
66
+ | `remove_secret` | Delete a secret |
67
+ | `list_promotions` | Promotion requests with approval state |
68
+ | `approve_promotion` | Approve a queued promotion |
69
+ | `list_users` | All user accounts (owner only) |
70
+ | `get_audit_log` | Recent audit entries (owner only) |
71
+ | `get_server_config` | Server-level config and theme |
72
+ | `set_server_config` | Update server config or theme |
73
+ | `list_buildpack_plugins` | Installed buildpack plugins |
74
+ | `remove_buildpack_plugin` | Remove a buildpack plugin |
75
+ | `get_version` | relayd and Station version info |
76
+ | `create_signed_link` | Time-bounded share URL for signed-link lanes |
77
+ | `list_companions` | Companion services for an app lane |
78
+ | `restart_companion` | Restart a companion service |
package/index.js ADDED
@@ -0,0 +1,450 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * relay-mcp — Model Context Protocol server for Relay
4
+ *
5
+ * Connect to relayd via:
6
+ * RELAY_SOCKET=/path/to/relay.sock Unix socket (no token needed — filesystem ACL is auth)
7
+ * RELAY_URL=http://server:8080 HTTP (requires RELAY_TOKEN)
8
+ * RELAY_TOKEN=your-token
9
+ *
10
+ * Usage in MCP config:
11
+ * {
12
+ * "mcpServers": {
13
+ * "relay": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@relay-org/relay-mcp"],
16
+ * "env": {
17
+ * "RELAY_URL": "http://your-server:8080",
18
+ * "RELAY_TOKEN": "your-token"
19
+ * }
20
+ * }
21
+ * }
22
+ * }
23
+ */
24
+ "use strict";
25
+
26
+ const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
27
+ const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
28
+ const { z } = require("zod");
29
+ const http = require("http");
30
+ const https = require("https");
31
+
32
+ // ─── Transport config ─────────────────────────────────────────────────────────
33
+
34
+ const RELAY_SOCKET = process.env.RELAY_SOCKET || null;
35
+ const RELAY_URL = (process.env.RELAY_URL || "http://127.0.0.1:8080").replace(/\/$/, "");
36
+ const RELAY_TOKEN = process.env.RELAY_TOKEN || "";
37
+
38
+ if (!RELAY_SOCKET && !RELAY_TOKEN) {
39
+ process.stderr.write(
40
+ "[relay-mcp] Warning: neither RELAY_SOCKET nor RELAY_TOKEN is set. " +
41
+ "Requests will likely be rejected. Set RELAY_SOCKET for local socket auth " +
42
+ "or RELAY_URL + RELAY_TOKEN for HTTP auth.\n"
43
+ );
44
+ }
45
+
46
+ // ─── HTTP helper ──────────────────────────────────────────────────────────────
47
+
48
+ /**
49
+ * Make a request to the Relay agent. Returns parsed JSON.
50
+ * @param {"GET"|"POST"|"DELETE"|"PATCH"} method
51
+ * @param {string} apiPath e.g. "/api/projects"
52
+ * @param {object|undefined} body JSON body for POST/DELETE/PATCH
53
+ */
54
+ function relayRequest(method, apiPath, body) {
55
+ return new Promise((resolve, reject) => {
56
+ const bodyStr = body !== undefined ? JSON.stringify(body) : null;
57
+
58
+ const headers = {
59
+ "Content-Type": "application/json",
60
+ Accept: "application/json",
61
+ };
62
+ if (RELAY_TOKEN) headers["X-Relay-Token"] = RELAY_TOKEN;
63
+ if (bodyStr) headers["Content-Length"] = String(Buffer.byteLength(bodyStr));
64
+
65
+ function handleResponse(res) {
66
+ const chunks = [];
67
+ res.on("data", (c) => chunks.push(c));
68
+ res.on("end", () => {
69
+ const raw = Buffer.concat(chunks).toString("utf8");
70
+ if (res.statusCode >= 400) {
71
+ let msg = raw.trim();
72
+ try { msg = JSON.parse(raw).error || msg; } catch {}
73
+ return reject(new Error(`HTTP ${res.statusCode}: ${msg}`));
74
+ }
75
+ if (!raw.trim()) return resolve({});
76
+ try {
77
+ resolve(JSON.parse(raw));
78
+ } catch {
79
+ resolve({ text: raw });
80
+ }
81
+ });
82
+ res.on("error", reject);
83
+ }
84
+
85
+ let req;
86
+ if (RELAY_SOCKET) {
87
+ req = http.request(
88
+ { socketPath: RELAY_SOCKET, method, path: apiPath, headers },
89
+ handleResponse
90
+ );
91
+ } else {
92
+ const url = new URL(RELAY_URL);
93
+ const isHttps = url.protocol === "https:";
94
+ const lib = isHttps ? https : http;
95
+ req = lib.request(
96
+ {
97
+ hostname: url.hostname,
98
+ port: url.port || (isHttps ? 443 : 80),
99
+ path: apiPath,
100
+ method,
101
+ headers,
102
+ },
103
+ handleResponse
104
+ );
105
+ }
106
+
107
+ req.on("error", reject);
108
+ if (bodyStr) req.write(bodyStr);
109
+ req.end();
110
+ });
111
+ }
112
+
113
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
114
+
115
+ function qs(params) {
116
+ const pairs = Object.entries(params).filter(
117
+ ([, v]) => v !== undefined && v !== null && v !== ""
118
+ );
119
+ if (!pairs.length) return "";
120
+ return "?" + pairs.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`).join("&");
121
+ }
122
+
123
+ function fmt(obj) {
124
+ return JSON.stringify(obj, null, 2);
125
+ }
126
+
127
+ function text(obj) {
128
+ return { content: [{ type: "text", text: fmt(obj) }] };
129
+ }
130
+
131
+ // ─── MCP server ───────────────────────────────────────────────────────────────
132
+
133
+ const server = new McpServer({
134
+ name: "relay",
135
+ version: "0.1.0",
136
+ });
137
+
138
+ // ── Projects ───────────────────────────────────────────────────────────────────
139
+
140
+ server.tool(
141
+ "list_projects",
142
+ "List all projects and their environments known to the Relay agent.",
143
+ {},
144
+ async () => text(await relayRequest("GET", "/api/projects"))
145
+ );
146
+
147
+ // ── Deploys ───────────────────────────────────────────────────────────────────
148
+
149
+ server.tool(
150
+ "list_deploys",
151
+ "List recent deploys. Filter by app, env, and/or branch. Returns build numbers, statuses, deployed_by, commit messages, and timestamps.",
152
+ {
153
+ app: z.string().optional().describe("App name"),
154
+ env: z.string().optional().describe("Environment: dev, staging, prod, etc."),
155
+ branch: z.string().optional().describe("Branch name"),
156
+ limit: z.number().int().min(1).max(200).default(20).describe("Max results"),
157
+ },
158
+ async ({ app, env, branch, limit }) =>
159
+ text(await relayRequest("GET", `/api/deploys${qs({ app, env, branch, limit })}`))
160
+ );
161
+
162
+ server.tool(
163
+ "get_deploy",
164
+ "Get a single deploy record by ID including status, logs reference, image tag, and commit info.",
165
+ { id: z.string().describe("Deploy ID") },
166
+ async ({ id }) =>
167
+ text(await relayRequest("GET", `/api/deploys/${encodeURIComponent(id)}`))
168
+ );
169
+
170
+ server.tool(
171
+ "get_deploy_logs",
172
+ "Fetch the stored build and deploy logs for a given deploy ID.",
173
+ { id: z.string().describe("Deploy ID") },
174
+ async ({ id }) => {
175
+ const data = await relayRequest("GET", `/api/logs/${encodeURIComponent(id)}`);
176
+ const out = typeof data.text === "string" ? data.text : fmt(data);
177
+ return { content: [{ type: "text", text: out }] };
178
+ }
179
+ );
180
+
181
+ server.tool(
182
+ "cancel_deploy",
183
+ "Cancel an in-progress deploy by ID.",
184
+ { id: z.string().describe("Deploy ID to cancel") },
185
+ async ({ id }) =>
186
+ text(await relayRequest("POST", `/api/deploys/cancel/${encodeURIComponent(id)}`))
187
+ );
188
+
189
+ server.tool(
190
+ "rollback",
191
+ "Roll back the most recent deploy to the previous image for an app/env/branch.",
192
+ {
193
+ app: z.string().describe("App name"),
194
+ env: z.string().describe("Environment"),
195
+ branch: z.string().default("main").describe("Branch"),
196
+ },
197
+ async ({ app, env, branch }) =>
198
+ text(await relayRequest("POST", "/api/deploys/rollback", { app, env, branch }))
199
+ );
200
+
201
+ // ── App control ───────────────────────────────────────────────────────────────
202
+
203
+ const appParams = {
204
+ app: z.string().describe("App name"),
205
+ env: z.string().describe("Environment"),
206
+ branch: z.string().default("main").describe("Branch"),
207
+ };
208
+
209
+ server.tool(
210
+ "start_app",
211
+ "Start a stopped app container without triggering a new build.",
212
+ appParams,
213
+ async ({ app, env, branch }) =>
214
+ text(await relayRequest("POST", "/api/apps/start", { app, env, branch }))
215
+ );
216
+
217
+ server.tool(
218
+ "stop_app",
219
+ "Stop a running app container.",
220
+ appParams,
221
+ async ({ app, env, branch }) =>
222
+ text(await relayRequest("POST", "/api/apps/stop", { app, env, branch }))
223
+ );
224
+
225
+ server.tool(
226
+ "restart_app",
227
+ "Restart a running app container in place without a new build.",
228
+ appParams,
229
+ async ({ app, env, branch }) =>
230
+ text(await relayRequest("POST", "/api/apps/restart", { app, env, branch }))
231
+ );
232
+
233
+ server.tool(
234
+ "delete_lane",
235
+ "Delete a lane and remove its container, workspace, and state from the agent.",
236
+ appParams,
237
+ async ({ app, env, branch }) =>
238
+ text(await relayRequest("POST", "/api/apps/delete-lane", { app, env, branch }))
239
+ );
240
+
241
+ // ── App config ────────────────────────────────────────────────────────────────
242
+
243
+ server.tool(
244
+ "get_app_config",
245
+ "Get the current lane configuration for an app including access_policy, public_host, engine, host_port, service_port, and rollout settings.",
246
+ appParams,
247
+ async ({ app, env, branch }) =>
248
+ text(await relayRequest("GET", `/api/apps/config${qs({ app, env, branch })}`))
249
+ );
250
+
251
+ server.tool(
252
+ "set_app_config",
253
+ "Update lane configuration for an app. Only supply the fields you want to change.",
254
+ {
255
+ app: z.string(),
256
+ env: z.string(),
257
+ branch: z.string().default("main"),
258
+ access_policy: z.enum(["public", "relay-login", "signed-link", "ip-allowlist"]).optional()
259
+ .describe("Who can access the lane"),
260
+ ip_allowlist: z.string().optional().describe("Comma-separated CIDRs for ip-allowlist policy"),
261
+ public_host: z.string().optional().describe("Custom public hostname"),
262
+ host_port: z.number().int().optional().describe("External host port"),
263
+ service_port: z.number().int().optional().describe("Container service port"),
264
+ engine: z.enum(["docker", "station"]).optional(),
265
+ webhook_secret: z.string().optional().describe("Per-app GitHub webhook secret"),
266
+ expires_at: z.number().optional().describe("Lane expiry as Unix ms timestamp"),
267
+ },
268
+ async ({ app, env, branch, ...rest }) => {
269
+ const payload = { app, env, branch, ...Object.fromEntries(
270
+ Object.entries(rest).filter(([, v]) => v !== undefined)
271
+ )};
272
+ return text(await relayRequest("POST", "/api/apps/config", payload));
273
+ }
274
+ );
275
+
276
+ // ── Secrets ───────────────────────────────────────────────────────────────────
277
+
278
+ server.tool(
279
+ "list_secrets",
280
+ "List secret key names (not values) for an app/env/branch.",
281
+ appParams,
282
+ async ({ app, env, branch }) =>
283
+ text(await relayRequest("GET", `/api/apps/secrets${qs({ app, env, branch })}`))
284
+ );
285
+
286
+ server.tool(
287
+ "add_secret",
288
+ "Add or update a secret value for an app/env/branch. The value is encrypted at rest if RELAY_SECRET_KEY is configured on the agent.",
289
+ {
290
+ app: z.string(),
291
+ env: z.string(),
292
+ branch: z.string().default("main"),
293
+ key: z.string().describe("Secret key name"),
294
+ value: z.string().describe("Secret value"),
295
+ },
296
+ async ({ app, env, branch, key, value }) =>
297
+ text(await relayRequest("POST", "/api/apps/secrets", { app, env, branch, key, value }))
298
+ );
299
+
300
+ server.tool(
301
+ "remove_secret",
302
+ "Delete a secret by key for an app/env/branch.",
303
+ {
304
+ app: z.string(),
305
+ env: z.string(),
306
+ branch: z.string().default("main"),
307
+ key: z.string().describe("Secret key name to delete"),
308
+ },
309
+ async ({ app, env, branch, key }) =>
310
+ text(await relayRequest("DELETE", "/api/apps/secrets", { app, env, branch, key }))
311
+ );
312
+
313
+ // ── Promotions ────────────────────────────────────────────────────────────────
314
+
315
+ server.tool(
316
+ "list_promotions",
317
+ "List promotion requests. Shows source/target lane, image, approval state, and post-promote health status.",
318
+ {
319
+ app: z.string().optional().describe("Filter by app name"),
320
+ status: z.string().optional().describe("Filter by status: pending, approved, running, success, failed"),
321
+ },
322
+ async ({ app, status }) =>
323
+ text(await relayRequest("GET", `/api/promotions${qs({ app, status })}`))
324
+ );
325
+
326
+ server.tool(
327
+ "approve_promotion",
328
+ "Approve a queued promotion request by ID. Requires owner role.",
329
+ { id: z.string().describe("Promotion ID") },
330
+ async ({ id }) =>
331
+ text(await relayRequest("POST", "/api/promotions/approve", { id }))
332
+ );
333
+
334
+ // ── Users and audit ───────────────────────────────────────────────────────────
335
+
336
+ server.tool(
337
+ "list_users",
338
+ "List all user accounts with their roles. Requires owner role.",
339
+ {},
340
+ async () => text(await relayRequest("GET", "/api/users"))
341
+ );
342
+
343
+ server.tool(
344
+ "get_audit_log",
345
+ "Fetch recent audit log entries showing actor, action, target, and timestamp. Requires owner role.",
346
+ {
347
+ limit: z.number().int().min(1).max(500).default(50).describe("Max entries"),
348
+ },
349
+ async ({ limit }) =>
350
+ text(await relayRequest("GET", `/api/audit${qs({ limit })}`))
351
+ );
352
+
353
+ // ── Server config ─────────────────────────────────────────────────────────────
354
+
355
+ server.tool(
356
+ "get_server_config",
357
+ "Get server-level config: base_domain, dashboard_host, ACME settings, and active theme. Requires owner role.",
358
+ {},
359
+ async () => text(await relayRequest("GET", "/api/server/config"))
360
+ );
361
+
362
+ server.tool(
363
+ "set_server_config",
364
+ "Update server-level config. Supply only the fields to change. Requires owner role.",
365
+ {
366
+ base_domain: z.string().optional().describe("Wildcard base domain for managed lane hosts"),
367
+ dashboard_host: z.string().optional().describe("Custom hostname for the admin dashboard"),
368
+ acme_disabled: z.boolean().optional().describe("Disable automatic TLS via ACME"),
369
+ theme_name: z.string().optional().describe("Built-in theme name: default, midnight, slate, forest, rose, ocean"),
370
+ theme_css: z.string().optional().describe("Custom CSS to apply as the dashboard theme"),
371
+ },
372
+ async (params) => {
373
+ const payload = Object.fromEntries(
374
+ Object.entries(params).filter(([, v]) => v !== undefined)
375
+ );
376
+ return text(await relayRequest("POST", "/api/server/config", payload));
377
+ }
378
+ );
379
+
380
+ // ── Plugins ───────────────────────────────────────────────────────────────────
381
+
382
+ server.tool(
383
+ "list_buildpack_plugins",
384
+ "List installed buildpack plugins on the agent.",
385
+ {},
386
+ async () => text(await relayRequest("GET", "/api/plugins/buildpacks"))
387
+ );
388
+
389
+ server.tool(
390
+ "remove_buildpack_plugin",
391
+ "Remove an installed buildpack plugin by name. Requires RELAY_ENABLE_PLUGIN_MUTATIONS=true on the agent.",
392
+ { name: z.string().describe("Plugin name to remove") },
393
+ async ({ name }) =>
394
+ text(await relayRequest("DELETE", `/api/plugins/buildpacks/${encodeURIComponent(name)}`))
395
+ );
396
+
397
+ // ── Version ───────────────────────────────────────────────────────────────────
398
+
399
+ server.tool(
400
+ "get_version",
401
+ "Get relayd build metadata and Station runtime version details.",
402
+ {},
403
+ async () => text(await relayRequest("GET", "/api/version"))
404
+ );
405
+
406
+ // ── Signed links ──────────────────────────────────────────────────────────────
407
+
408
+ server.tool(
409
+ "create_signed_link",
410
+ "Generate a time-bounded signed share URL for a lane that uses access_policy=signed-link.",
411
+ {
412
+ app: z.string(),
413
+ env: z.string(),
414
+ branch: z.string().default("main"),
415
+ expires_in: z.number().int().min(60).default(3600).describe("Link lifetime in seconds"),
416
+ },
417
+ async ({ app, env, branch, expires_in }) =>
418
+ text(await relayRequest("POST", "/api/apps/signed-link", { app, env, branch, expires_in }))
419
+ );
420
+
421
+ // ── Companions ────────────────────────────────────────────────────────────────
422
+
423
+ server.tool(
424
+ "list_companions",
425
+ "List managed companion services (databases, caches, etc.) attached to an app lane.",
426
+ appParams,
427
+ async ({ app, env, branch }) =>
428
+ text(await relayRequest("GET", `/api/apps/companions${qs({ app, env, branch })}`))
429
+ );
430
+
431
+ server.tool(
432
+ "restart_companion",
433
+ "Restart a named companion service in place.",
434
+ {
435
+ app: z.string(),
436
+ env: z.string(),
437
+ branch: z.string().default("main"),
438
+ name: z.string().describe("Companion service name"),
439
+ },
440
+ async ({ app, env, branch, name }) =>
441
+ text(await relayRequest("POST", "/api/apps/companions/restart", { app, env, branch, name }))
442
+ );
443
+
444
+ // ─── Start ────────────────────────────────────────────────────────────────────
445
+
446
+ const transport = new StdioServerTransport();
447
+ server.connect(transport).catch((err) => {
448
+ process.stderr.write(`[relay-mcp] fatal: ${err.message}\n`);
449
+ process.exit(1);
450
+ });
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@relay-org/relay-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Model Context Protocol server for Relay — deploy, inspect, and control apps from any MCP-aware AI tool",
5
+ "type": "commonjs",
6
+ "bin": {
7
+ "relay-mcp": "index.js"
8
+ },
9
+ "scripts": {
10
+ "start": "node index.js"
11
+ },
12
+ "dependencies": {
13
+ "@modelcontextprotocol/sdk": "^1.10.2",
14
+ "zod": "^3.24.0"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "repository": {
20
+ "type": "git",
21
+ "url": "git+https://github.com/Relay-CI/Relay.git"
22
+ },
23
+ "homepage": "https://github.com/Relay-CI/Relay",
24
+ "bugs": {
25
+ "url": "https://github.com/Relay-CI/Relay/issues"
26
+ },
27
+ "keywords": [
28
+ "relay",
29
+ "mcp",
30
+ "model-context-protocol",
31
+ "deploy",
32
+ "self-hosted"
33
+ ],
34
+ "license": "MIT",
35
+ "publishConfig": {
36
+ "access": "public"
37
+ },
38
+ "files": [
39
+ "index.js",
40
+ "README.md"
41
+ ]
42
+ }