@rubytech/create-maxy 1.0.623 → 1.0.624
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/package.json +1 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js +1 -1
- package/payload/platform/plugins/admin/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/admin/skills/onboarding/SKILL.md +9 -12
- package/payload/platform/plugins/cloudflare/PLUGIN.md +31 -44
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js +13 -875
- package/payload/platform/plugins/cloudflare/mcp/dist/index.js.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.d.ts.map +1 -1
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js +1 -0
- package/payload/platform/plugins/cloudflare/mcp/dist/lib/cloudflared.js.map +1 -1
- package/payload/platform/plugins/cloudflare/references/dashboard-guide.md +108 -0
- package/payload/platform/plugins/cloudflare/references/manual-setup.md +445 -0
- package/payload/platform/plugins/cloudflare/references/reset-guide.md +118 -0
- package/payload/platform/plugins/cloudflare/scripts/reset-tunnel.sh +65 -0
- package/payload/platform/plugins/cloudflare/scripts/setup-tunnel.sh +244 -0
- package/payload/platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md +96 -5
- package/payload/platform/plugins/docs/references/cloudflare.md +91 -34
- package/payload/platform/templates/agents/admin/IDENTITY.md +10 -4
- package/payload/platform/templates/specialists/agents/personal-assistant.md +9 -9
- package/payload/server/server.js +187 -299
- package/payload/platform/config/cloudflared.yml +0 -17
- package/payload/platform/plugins/cloudflare/references/setup-guide.md +0 -132
|
@@ -2,883 +2,21 @@ import { initStderrTee } from "../../../../lib/mcp-stderr-tee/dist/index.js";
|
|
|
2
2
|
initStderrTee("cloudflare");
|
|
3
3
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return deviceUrlBlock({
|
|
18
|
-
url: authUrl,
|
|
19
|
-
intent: "Sign in to Cloudflare",
|
|
20
|
-
hostname: osHostname(),
|
|
21
|
-
});
|
|
22
|
-
}
|
|
5
|
+
// Task 554: the Cloudflare plugin exposes zero agent-facing tools. Every
|
|
6
|
+
// Cloudflare operation is driven by shell scripts (setup-tunnel.sh,
|
|
7
|
+
// reset-tunnel.sh) invoked via Bash, plus dashboard click-paths relayed
|
|
8
|
+
// verbatim from the skill's reference files. The MCP server process
|
|
9
|
+
// still spawns for lifecycle and stderr-tee consistency with other
|
|
10
|
+
// plugins, but its tool list is empty by design.
|
|
11
|
+
//
|
|
12
|
+
// See platform/plugins/cloudflare/skills/setup-tunnel/SKILL.md and
|
|
13
|
+
// platform/plugins/cloudflare/references/ for the operator-facing
|
|
14
|
+
// surface. `lib/cloudflared.ts` and `lib/setup-orchestrator.ts` are
|
|
15
|
+
// retained as private implementation layers that nothing currently
|
|
16
|
+
// imports; a follow-up task will delete them once that is confirmed.
|
|
23
17
|
const server = new McpServer({
|
|
24
18
|
name: "cloudflare",
|
|
25
|
-
version: "0.
|
|
26
|
-
});
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
// tunnel-status trailing guidance — deterministic 1:1 mapping from status
|
|
29
|
-
// enum → single prescribed recovery sentence.
|
|
30
|
-
//
|
|
31
|
-
// The trailing string is a prompt for the agent's next turn. Task 541 closed
|
|
32
|
-
// the recurrence loop where a "check in the browser" sentence primed a 44-
|
|
33
|
-
// tool-call dashboard cascade. The mapping here is exhaustive for every
|
|
34
|
-
// reachable `unhealthyReason`; there is no generic fallback branch. Each
|
|
35
|
-
// sentence names exactly one next action so the agent cannot fork on it.
|
|
36
|
-
// ---------------------------------------------------------------------------
|
|
37
|
-
const BOUND_ACCOUNT_MISMATCH_SENTENCE = (hostname) => `Sign in to Cloudflare at dash.cloudflare.com in the account that owns ${hostname} — ` +
|
|
38
|
-
`the account with the little orange cloud next to it in your browser tab — then tell me ` +
|
|
39
|
-
`and I'll restart the tunnel login.`;
|
|
40
|
-
function buildTunnelStatusGuidance(status) {
|
|
41
|
-
if (!status.unhealthyReason)
|
|
42
|
-
return "";
|
|
43
|
-
if (status.unhealthyReason === "not-running") {
|
|
44
|
-
return `\n\nThe tunnel process is not running. Ask the user if they want me to enable it.`;
|
|
45
|
-
}
|
|
46
|
-
if (status.unhealthyReason === "no-tunnel-configured") {
|
|
47
|
-
return `\n\nNo tunnel is configured on this laptop yet. Run tunnel-create to set one up.`;
|
|
48
|
-
}
|
|
49
|
-
if (status.unhealthyReason === "bound-account-does-not-own-hostname") {
|
|
50
|
-
// Use the first configured hostname as the anchor — matches how the
|
|
51
|
-
// operator thinks about the setup. If configuredHostnames is empty
|
|
52
|
-
// (unreachable here because the enum is only set when probes ran)
|
|
53
|
-
// fall back to domain.
|
|
54
|
-
const anchor = status.configuredHostnames[0] ??
|
|
55
|
-
status.persistedHostnames[0] ??
|
|
56
|
-
status.domain ??
|
|
57
|
-
"your domain";
|
|
58
|
-
return `\n\n${BOUND_ACCOUNT_MISMATCH_SENTENCE(anchor)}`;
|
|
59
|
-
}
|
|
60
|
-
if (status.unhealthyReason === "hostname-probes-failed") {
|
|
61
|
-
// Dominant per-hostname mode drives the sentence. All failing probes
|
|
62
|
-
// share one mode in the expected failure classes — when they diverge,
|
|
63
|
-
// the first failure wins (the per-hostname list is already printed in
|
|
64
|
-
// the JSON above; the sentence names the one action the agent takes).
|
|
65
|
-
const firstFailure = status.probes.find((p) => p.failureMode !== "ok");
|
|
66
|
-
const mode = firstFailure?.failureMode ?? "edge-unreachable";
|
|
67
|
-
if (mode === "tunnel-not-matched" || mode === "cname-points-elsewhere") {
|
|
68
|
-
return `\n\nDNS is pointing at a different tunnel. I'll re-point it.`;
|
|
69
|
-
}
|
|
70
|
-
if (mode === "dns-missing") {
|
|
71
|
-
return `\n\nDNS is not set up yet. I'll create it.`;
|
|
72
|
-
}
|
|
73
|
-
// edge-unreachable — transient Cloudflare edge or connectivity; the
|
|
74
|
-
// agent should not immediately re-probe or open the dashboard.
|
|
75
|
-
return `\n\nThe tunnel is not reachable through Cloudflare right now. This usually clears in a minute.`;
|
|
76
|
-
}
|
|
77
|
-
// Exhaustiveness guard — a new enum value must be added to this switch
|
|
78
|
-
// before it ever appears in a tool result. Returning empty string would
|
|
79
|
-
// let a novel failure mode silently emit JSON-only output. The bare
|
|
80
|
-
// assignment (no `as never` cast) is the point: TypeScript narrows
|
|
81
|
-
// `status.unhealthyReason` to `never` after every branch above returns,
|
|
82
|
-
// so a new `TunnelUnhealthyReason` value introduced without a branch
|
|
83
|
-
// here fails at compile time.
|
|
84
|
-
const _exhaustive = status.unhealthyReason;
|
|
85
|
-
console.error(`[cloudflare:tunnel-status:unknown-reason] reason=${String(_exhaustive)}`);
|
|
86
|
-
return "";
|
|
87
|
-
}
|
|
88
|
-
// ===================================================================
|
|
89
|
-
// Authentication — OAuth cert.pem only
|
|
90
|
-
//
|
|
91
|
-
// The plugin recognises exactly one identity: the Cloudflare account
|
|
92
|
-
// cert.pem was bound to via `cloudflared tunnel login`. No API token
|
|
93
|
-
// handling, no SDK, no read of account state by any code path. The
|
|
94
|
-
// operator's logged-in Cloudflare dashboard session is the source of
|
|
95
|
-
// truth for which domains exist and who owns them; the agent's role
|
|
96
|
-
// is to instruct the operator where to click.
|
|
97
|
-
// ===================================================================
|
|
98
|
-
server.tool("tunnel-login", "Authenticate with Cloudflare via `cloudflared tunnel login`. Three phases: (1) if cert.pem exists and matches the recorded account binding, reports status; (2) if cert.pem exists but no binding has been recorded, materializes the binding from the cert (migration path); (3) if no cert.pem, spawns login process and returns the auth URL. Detects existing login processes — safe to call repeatedly without spawning duplicates. Pass force=true to delete cert.pem (both brand-specific and legacy paths) and account-binding.json before re-authenticating — required when switching Cloudflare accounts.", {
|
|
99
|
-
force: z
|
|
100
|
-
.boolean()
|
|
101
|
-
.optional()
|
|
102
|
-
.describe("Delete cert.pem (brand path + legacy ~/.cloudflared/cert.pem) and account-binding.json before re-authenticating. Required when switching Cloudflare accounts (e.g. after the prior account was scrubbed or the cert was rotated under a different account)."),
|
|
103
|
-
}, async ({ force }) => {
|
|
104
|
-
try {
|
|
105
|
-
if (force) {
|
|
106
|
-
console.error("[tunnel-login] force=true — clearing cert.pem and account binding before re-auth");
|
|
107
|
-
cloudflared.resetAuth();
|
|
108
|
-
}
|
|
109
|
-
const auth = cloudflared.validateAuth();
|
|
110
|
-
if (auth.bound) {
|
|
111
|
-
console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.boundAccountId}`);
|
|
112
|
-
return {
|
|
113
|
-
content: [
|
|
114
|
-
{
|
|
115
|
-
type: "text",
|
|
116
|
-
text: `Sign-in complete.`,
|
|
117
|
-
},
|
|
118
|
-
],
|
|
119
|
-
};
|
|
120
|
-
}
|
|
121
|
-
if (auth.hasCert) {
|
|
122
|
-
const creds = cloudflared.parseCertPem();
|
|
123
|
-
if (!creds) {
|
|
124
|
-
return {
|
|
125
|
-
content: [
|
|
126
|
-
{
|
|
127
|
-
type: "text",
|
|
128
|
-
text: `Sign-in file exists but its contents are unreadable. Ask me to run tunnel-login with force=true so the file is cleared and a fresh sign-in can start.`,
|
|
129
|
-
},
|
|
130
|
-
],
|
|
131
|
-
isError: true,
|
|
132
|
-
};
|
|
133
|
-
}
|
|
134
|
-
if (!auth.hasBinding) {
|
|
135
|
-
cloudflared.materializeBinding("migration");
|
|
136
|
-
console.error(`[cloudflare:tunnel-login:complete] accountId=${auth.certAccountId} source=migration`);
|
|
137
|
-
return {
|
|
138
|
-
content: [
|
|
139
|
-
{
|
|
140
|
-
type: "text",
|
|
141
|
-
text: `Sign-in complete.`,
|
|
142
|
-
},
|
|
143
|
-
],
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
if (auth.certAccountId !== auth.boundAccountId) {
|
|
147
|
-
return {
|
|
148
|
-
content: [
|
|
149
|
-
{
|
|
150
|
-
type: "text",
|
|
151
|
-
text: `The Cloudflare sign-in on this laptop has changed since it was first set up. ${cloudflared.recoveryMessage()}`,
|
|
152
|
-
},
|
|
153
|
-
],
|
|
154
|
-
isError: true,
|
|
155
|
-
};
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
if (!cloudflared.isInstalled()) {
|
|
159
|
-
return {
|
|
160
|
-
content: [
|
|
161
|
-
{
|
|
162
|
-
type: "text",
|
|
163
|
-
text: "cloudflared is not installed. Run tunnel-install first.",
|
|
164
|
-
},
|
|
165
|
-
],
|
|
166
|
-
isError: true,
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
// Three-state login machine. `complete` is already handled above via
|
|
170
|
-
// `auth.bound` / migration path, so here we branch on in-progress vs.
|
|
171
|
-
// terminal failure (PID dead + cert absent, regardless of why). Both
|
|
172
|
-
// branches emit a `maxy-device-url` fenced block so the chat UI
|
|
173
|
-
// renders a click-to-open-on-device button — cloudflared's courtesy
|
|
174
|
-
// browser-launch failing no longer matters to the operator because
|
|
175
|
-
// the button drives the device's VNC browser directly.
|
|
176
|
-
const loginStatus = cloudflared.getLoginStatus();
|
|
177
|
-
if (loginStatus.state === "failed") {
|
|
178
|
-
console.error(`[cloudflare:tunnel-login:failed] reason=${loginStatus.reason} ` +
|
|
179
|
-
`pid=${loginStatus.loginState?.pid ?? "none"}`);
|
|
180
|
-
cloudflared.resetAuth();
|
|
181
|
-
const restarted = await cloudflared.tunnelLogin();
|
|
182
|
-
return {
|
|
183
|
-
content: [
|
|
184
|
-
{
|
|
185
|
-
type: "text",
|
|
186
|
-
text: `Sign-in ended without saving the cert. Restarting.\n\n` +
|
|
187
|
-
`${cloudflareSignInBlock(restarted.authUrl)}\n\n` +
|
|
188
|
-
`Click the button to open the Cloudflare sign-in page on the device's browser. ` +
|
|
189
|
-
`Pick the Cloudflare account you want this laptop signed into, then click Authorize. ` +
|
|
190
|
-
`Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
|
|
191
|
-
},
|
|
192
|
-
],
|
|
193
|
-
};
|
|
194
|
-
}
|
|
195
|
-
if (loginStatus.state === "in-progress") {
|
|
196
|
-
console.error(`[cloudflare:tunnel-login:waiting] pid=${loginStatus.loginState.pid} authUrl=${loginStatus.loginState.authUrl}`);
|
|
197
|
-
if (loginStatus.browserLaunchFailed) {
|
|
198
|
-
// Task 545 observability: cloudflared's browser-launch subcommand
|
|
199
|
-
// failed but the OAuth callback loop is still alive. Task 546
|
|
200
|
-
// resolves the user-facing half of this — the device-URL block
|
|
201
|
-
// below renders as a click-to-open-on-device button in the chat
|
|
202
|
-
// UI, so the operator does not need to know cloudflared's
|
|
203
|
-
// launcher misbehaved. The log line remains for diagnostics.
|
|
204
|
-
console.error(`[cloudflare:tunnel-login:browser-launch-failed] pid=${loginStatus.loginState.pid}`);
|
|
205
|
-
}
|
|
206
|
-
return {
|
|
207
|
-
content: [
|
|
208
|
-
{
|
|
209
|
-
type: "text",
|
|
210
|
-
text: `Sign-in in progress — a browser window is already open on the device (started ${new Date(loginStatus.loginState.startedAt).toISOString()}).\n\n` +
|
|
211
|
-
`${cloudflareSignInBlock(loginStatus.loginState.authUrl)}\n\n` +
|
|
212
|
-
`Finish the sign-in on the device's browser and click Authorize. ` +
|
|
213
|
-
`Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
|
|
214
|
-
},
|
|
215
|
-
],
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
// state === "idle" — spawn fresh.
|
|
219
|
-
const result = await cloudflared.tunnelLogin();
|
|
220
|
-
return {
|
|
221
|
-
content: [
|
|
222
|
-
{
|
|
223
|
-
type: "text",
|
|
224
|
-
text: `Cloudflare sign-in started on the device.\n\n` +
|
|
225
|
-
`${cloudflareSignInBlock(result.authUrl)}\n\n` +
|
|
226
|
-
`Click the button to open the sign-in page on the device's browser. ` +
|
|
227
|
-
`Pick the Cloudflare account you want this laptop signed into, then click Authorize. ` +
|
|
228
|
-
`Call tunnel-login again once you confirm — it will detect the completed sign-in automatically.`,
|
|
229
|
-
},
|
|
230
|
-
],
|
|
231
|
-
};
|
|
232
|
-
}
|
|
233
|
-
catch (err) {
|
|
234
|
-
return {
|
|
235
|
-
content: [
|
|
236
|
-
{
|
|
237
|
-
type: "text",
|
|
238
|
-
text: `Sign-in failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
239
|
-
},
|
|
240
|
-
],
|
|
241
|
-
isError: true,
|
|
242
|
-
};
|
|
243
|
-
}
|
|
244
|
-
});
|
|
245
|
-
server.tool("tunnel-status", "End-to-end Cloudflare Tunnel status. Reads the tunnel process state, parses configured hostnames from config.yml, and probes each hostname (DNS + HTTPS via Cloudflare's edge) to confirm traffic actually reaches this laptop from the internet. `healthy: true` requires every configured hostname to probe `ok`. `boundAccountOwnsHostnames: false` means the laptop is running a tunnel that nothing on the internet can reach — almost always because it is signed into a Cloudflare account that does not own the domain.", {
|
|
246
|
-
domain: z
|
|
247
|
-
.string()
|
|
248
|
-
.optional()
|
|
249
|
-
.describe("The bare domain (e.g. 'maxy.bot'). If omitted, derived from persisted state."),
|
|
250
|
-
}, async ({ domain }) => {
|
|
251
|
-
try {
|
|
252
|
-
const status = await cloudflared.getStatus(domain);
|
|
253
|
-
const guidance = buildTunnelStatusGuidance(status);
|
|
254
|
-
return {
|
|
255
|
-
content: [
|
|
256
|
-
{
|
|
257
|
-
type: "text",
|
|
258
|
-
text: JSON.stringify(status, null, 2) + guidance,
|
|
259
|
-
},
|
|
260
|
-
],
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
catch (err) {
|
|
264
|
-
return {
|
|
265
|
-
content: [
|
|
266
|
-
{
|
|
267
|
-
type: "text",
|
|
268
|
-
text: `Failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
269
|
-
},
|
|
270
|
-
],
|
|
271
|
-
isError: true,
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
server.tool("tunnel-install", "Install the cloudflared binary if not already present.", {}, async () => {
|
|
276
|
-
const { execFileSync } = await import("node:child_process");
|
|
277
|
-
if (cloudflared.isInstalled()) {
|
|
278
|
-
const v = cloudflared.version();
|
|
279
|
-
return {
|
|
280
|
-
content: [
|
|
281
|
-
{
|
|
282
|
-
type: "text",
|
|
283
|
-
text: `cloudflared is already installed (v${v}).`,
|
|
284
|
-
},
|
|
285
|
-
],
|
|
286
|
-
};
|
|
287
|
-
}
|
|
288
|
-
try {
|
|
289
|
-
const arch = process.arch === "arm64" ? "arm64" : "amd64";
|
|
290
|
-
const debUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${arch}.deb`;
|
|
291
|
-
execFileSync("curl", ["-fsSL", debUrl, "-o", "/tmp/cloudflared.deb"], {
|
|
292
|
-
timeout: 60000,
|
|
293
|
-
});
|
|
294
|
-
execFileSync("sudo", ["dpkg", "-i", "/tmp/cloudflared.deb"], {
|
|
295
|
-
timeout: 30000,
|
|
296
|
-
});
|
|
297
|
-
execFileSync("rm", ["-f", "/tmp/cloudflared.deb"]);
|
|
298
|
-
return {
|
|
299
|
-
content: [
|
|
300
|
-
{
|
|
301
|
-
type: "text",
|
|
302
|
-
text: `cloudflared installed (v${cloudflared.version()}).`,
|
|
303
|
-
},
|
|
304
|
-
],
|
|
305
|
-
};
|
|
306
|
-
}
|
|
307
|
-
catch (err) {
|
|
308
|
-
return {
|
|
309
|
-
content: [
|
|
310
|
-
{
|
|
311
|
-
type: "text",
|
|
312
|
-
text: `Installation failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
313
|
-
},
|
|
314
|
-
],
|
|
315
|
-
isError: true,
|
|
316
|
-
};
|
|
317
|
-
}
|
|
318
|
-
});
|
|
319
|
-
server.tool("tunnel-create", "Create a Cloudflare Tunnel and route DNS for the specified subdomains. The admin subdomain is required (e.g. 'admin' → admin.{domain}, 'joel' → joel.{domain}). The public subdomain is optional — omit to skip the public endpoint. Shells out to `cloudflared` which uses the signed-in cert.pem; refuses when the configured domain is not on the signed-in Cloudflare account. Idempotent — reuses existing tunnel if name matches.", {
|
|
320
|
-
domain: z
|
|
321
|
-
.string()
|
|
322
|
-
.describe("The bare domain (e.g. 'maxy.bot')."),
|
|
323
|
-
adminSubdomain: z
|
|
324
|
-
.string()
|
|
325
|
-
.min(1)
|
|
326
|
-
.describe("Subdomain for the admin interface (e.g. 'admin', 'joel'). Creates {adminSubdomain}.{domain}."),
|
|
327
|
-
publicSubdomain: z
|
|
328
|
-
.string()
|
|
329
|
-
.min(1)
|
|
330
|
-
.optional()
|
|
331
|
-
.describe("Subdomain for the public chat (e.g. 'public', 'shop'). Creates {publicSubdomain}.{domain}. Omit to skip the public endpoint."),
|
|
332
|
-
tunnelName: z
|
|
333
|
-
.string()
|
|
334
|
-
.optional()
|
|
335
|
-
.describe("Human-readable tunnel name (default: derived from domain)"),
|
|
336
|
-
}, async ({ domain, adminSubdomain, publicSubdomain, tunnelName }) => {
|
|
337
|
-
if (publicSubdomain && adminSubdomain === publicSubdomain) {
|
|
338
|
-
return {
|
|
339
|
-
content: [
|
|
340
|
-
{
|
|
341
|
-
type: "text",
|
|
342
|
-
text: `Invalid: adminSubdomain and publicSubdomain are both "${adminSubdomain}". They must be different.`,
|
|
343
|
-
},
|
|
344
|
-
],
|
|
345
|
-
isError: true,
|
|
346
|
-
};
|
|
347
|
-
}
|
|
348
|
-
try {
|
|
349
|
-
const auth = cloudflared.validateAuth();
|
|
350
|
-
if (!auth.bound) {
|
|
351
|
-
return {
|
|
352
|
-
content: [
|
|
353
|
-
{
|
|
354
|
-
type: "text",
|
|
355
|
-
text: `REFUSED: this laptop is not signed into Cloudflare yet. ${cloudflared.recoveryMessage()}`,
|
|
356
|
-
},
|
|
357
|
-
],
|
|
358
|
-
isError: true,
|
|
359
|
-
};
|
|
360
|
-
}
|
|
361
|
-
const name = tunnelName ?? domain.replace(/\./g, "-");
|
|
362
|
-
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
363
|
-
const adminHostname = `${adminSubdomain}.${domain}`;
|
|
364
|
-
const publicHostname = publicSubdomain ? `${publicSubdomain}.${domain}` : null;
|
|
365
|
-
const hostnames = publicHostname ? [adminHostname, publicHostname] : [adminHostname];
|
|
366
|
-
console.error(`[tunnel-create] hostnames=${JSON.stringify(hostnames)} domain=${domain}`);
|
|
367
|
-
// Step 1: Create tunnel via CLI (idempotent — reuses if name exists)
|
|
368
|
-
const tunnel = cloudflared.createTunnelCli(name);
|
|
369
|
-
// Step 2: Write local config.yml with ingress rules
|
|
370
|
-
const configPath = cloudflared.writeLocalConfig(tunnel.tunnelId, tunnel.credentialsPath, hostnames, platformPort);
|
|
371
|
-
// Persist tunnel identity with actual hostnames
|
|
372
|
-
cloudflared.saveTunnelIdentity({
|
|
373
|
-
tunnelId: tunnel.tunnelId,
|
|
374
|
-
tunnelName: tunnel.tunnelName,
|
|
375
|
-
domain,
|
|
376
|
-
configPath,
|
|
377
|
-
credentialsPath: tunnel.credentialsPath,
|
|
378
|
-
adminHostname,
|
|
379
|
-
publicHostname,
|
|
380
|
-
});
|
|
381
|
-
// Step 3: Route DNS via CLI for each hostname (pre-flight + post-flight in routeDnsCli)
|
|
382
|
-
const dnsResults = [];
|
|
383
|
-
for (const h of hostnames) {
|
|
384
|
-
const route = await cloudflared.routeDnsCli(tunnel.tunnelId, h);
|
|
385
|
-
dnsResults.push({
|
|
386
|
-
hostname: h,
|
|
387
|
-
fqdn: route.fqdn,
|
|
388
|
-
note: route.created ? "created" : "already existed",
|
|
389
|
-
});
|
|
390
|
-
}
|
|
391
|
-
// Step 4: Verify hostnames resolve via public DNS
|
|
392
|
-
const resolver = new Resolver();
|
|
393
|
-
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
394
|
-
const dnsWarnings = [];
|
|
395
|
-
for (const h of hostnames) {
|
|
396
|
-
try {
|
|
397
|
-
await resolver.resolve4(h);
|
|
398
|
-
}
|
|
399
|
-
catch {
|
|
400
|
-
dnsWarnings.push(h);
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
let dnsNote = "";
|
|
404
|
-
if (dnsWarnings.length > 0) {
|
|
405
|
-
dnsNote = `\n\nDNS WARNING: ${dnsWarnings.join(", ")} did not resolve via public DNS yet. DNS propagation typically takes 1-5 minutes. Re-run tunnel-status in a few minutes to confirm.`;
|
|
406
|
-
}
|
|
407
|
-
const dnsLines = dnsResults
|
|
408
|
-
.map((r) => ` ${r.hostname} → this tunnel (${r.note})`)
|
|
409
|
-
.join("\n");
|
|
410
|
-
return {
|
|
411
|
-
content: [
|
|
412
|
-
{
|
|
413
|
-
type: "text",
|
|
414
|
-
text: `Tunnel created.\n\n Name: ${tunnel.tunnelName}\n${dnsLines}\n Config: ${configPath}\n\nUse tunnel-enable to start the tunnel.${dnsNote}`,
|
|
415
|
-
},
|
|
416
|
-
],
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
catch (err) {
|
|
420
|
-
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
421
|
-
return {
|
|
422
|
-
content: [{ type: "text", text: err.refusal.message }],
|
|
423
|
-
isError: true,
|
|
424
|
-
};
|
|
425
|
-
}
|
|
426
|
-
return {
|
|
427
|
-
content: [
|
|
428
|
-
{
|
|
429
|
-
type: "text",
|
|
430
|
-
text: `Create failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
431
|
-
},
|
|
432
|
-
],
|
|
433
|
-
isError: true,
|
|
434
|
-
};
|
|
435
|
-
}
|
|
436
|
-
});
|
|
437
|
-
server.tool("tunnel-enable", "Start the Cloudflare Tunnel process. The tunnel must be created first via tunnel-create. Reads hostnames from persisted state for DNS verification and reachability checks. If tunnelId and domain are omitted, reads them from persisted tunnel state.", {
|
|
438
|
-
tunnelId: z.string().optional().describe("Tunnel UUID from tunnel-create. If omitted, reads from persisted tunnel state."),
|
|
439
|
-
domain: z.string().optional().describe("The bare domain (e.g. 'maxy.bot'). If omitted, reads from persisted tunnel state."),
|
|
440
|
-
}, async ({ tunnelId: tunnelIdParam, domain: domainParam }) => {
|
|
441
|
-
try {
|
|
442
|
-
const tunnelId = tunnelIdParam ?? cloudflared.getPersistedTunnelId();
|
|
443
|
-
const domain = domainParam ?? cloudflared.getPersistedDomain();
|
|
444
|
-
if (!tunnelId) {
|
|
445
|
-
return {
|
|
446
|
-
content: [
|
|
447
|
-
{
|
|
448
|
-
type: "text",
|
|
449
|
-
text: "No tunnel found on this laptop. Run tunnel-create first.",
|
|
450
|
-
},
|
|
451
|
-
],
|
|
452
|
-
isError: true,
|
|
453
|
-
};
|
|
454
|
-
}
|
|
455
|
-
if (!domain) {
|
|
456
|
-
return {
|
|
457
|
-
content: [
|
|
458
|
-
{
|
|
459
|
-
type: "text",
|
|
460
|
-
text: "No domain recorded for this tunnel. Run tunnel-create first to set one up with a domain.",
|
|
461
|
-
},
|
|
462
|
-
],
|
|
463
|
-
isError: true,
|
|
464
|
-
};
|
|
465
|
-
}
|
|
466
|
-
const hostnames = cloudflared.getPersistedHostnames();
|
|
467
|
-
if (hostnames.length === 0) {
|
|
468
|
-
return {
|
|
469
|
-
content: [
|
|
470
|
-
{
|
|
471
|
-
type: "text",
|
|
472
|
-
text: "No addresses recorded for this tunnel. Run tunnel-create first.",
|
|
473
|
-
},
|
|
474
|
-
],
|
|
475
|
-
isError: true,
|
|
476
|
-
};
|
|
477
|
-
}
|
|
478
|
-
console.error(`[tunnel-enable] checking hostnames=${JSON.stringify(hostnames)}`);
|
|
479
|
-
// GATE 1: Check if actual hostnames resolve via public DNS
|
|
480
|
-
const resolver = new Resolver();
|
|
481
|
-
resolver.setServers(["1.1.1.1", "8.8.8.8"]);
|
|
482
|
-
const unresolvedSubs = [];
|
|
483
|
-
for (const h of hostnames) {
|
|
484
|
-
try {
|
|
485
|
-
await resolver.resolve4(h);
|
|
486
|
-
}
|
|
487
|
-
catch {
|
|
488
|
-
unresolvedSubs.push(h);
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
if (unresolvedSubs.length > 0) {
|
|
492
|
-
return {
|
|
493
|
-
content: [
|
|
494
|
-
{
|
|
495
|
-
type: "text",
|
|
496
|
-
text: `REFUSED: ${unresolvedSubs.join(", ")} do not resolve via public DNS. The tunnel's URLs will not work.\n\nUsually this means the DNS records were never created or are still propagating. Run tunnel-create first. If that has already run, wait 1-5 minutes for propagation or ask the user to confirm in the Cloudflare dashboard that the addresses exist on the correct domain.\n\nDo not call tunnel-enable until the addresses resolve.`,
|
|
497
|
-
},
|
|
498
|
-
],
|
|
499
|
-
isError: true,
|
|
500
|
-
};
|
|
501
|
-
}
|
|
502
|
-
const brand = cloudflared.loadBrand();
|
|
503
|
-
const platformPort = parseInt(process.env.PLATFORM_PORT ?? "19200", 10);
|
|
504
|
-
console.error(`[tunnel-enable] using PLATFORM_PORT=${platformPort}`);
|
|
505
|
-
// GATE 2: Remote auth must be configured
|
|
506
|
-
try {
|
|
507
|
-
const res = await fetch(`http://127.0.0.1:${platformPort}/api/remote-auth/status`, {
|
|
508
|
-
signal: AbortSignal.timeout(5000),
|
|
509
|
-
});
|
|
510
|
-
const body = await res.json();
|
|
511
|
-
if (!body.configured) {
|
|
512
|
-
const setupUrl = `http://${osHostname()}.local:${platformPort}/__remote-auth/setup`;
|
|
513
|
-
return {
|
|
514
|
-
content: [
|
|
515
|
-
{
|
|
516
|
-
type: "text",
|
|
517
|
-
text: `REFUSED: Remote access password is not configured. The admin interface would be exposed to the internet without authentication.\n\n` +
|
|
518
|
-
`${deviceUrlBlock({ url: setupUrl, intent: "Set remote access password", hostname: osHostname() })}\n\n` +
|
|
519
|
-
`Click the button to open the setup page on the device's browser, set the password, then ask me to enable the tunnel again. Do NOT collect the password in chat.`,
|
|
520
|
-
},
|
|
521
|
-
],
|
|
522
|
-
isError: true,
|
|
523
|
-
};
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
catch {
|
|
527
|
-
return {
|
|
528
|
-
content: [
|
|
529
|
-
{
|
|
530
|
-
type: "text",
|
|
531
|
-
text: `REFUSED: Cannot verify remote authentication — the web server at http://127.0.0.1:${platformPort} is not reachable. The admin interface cannot be exposed without a running auth gate.\n\nEnsure the ${brand.productName} platform is running before enabling the tunnel.`,
|
|
532
|
-
},
|
|
533
|
-
],
|
|
534
|
-
isError: true,
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
// GATE 3: cert.pem + binding
|
|
538
|
-
const enableAuth = cloudflared.validateAuth();
|
|
539
|
-
if (!enableAuth.bound) {
|
|
540
|
-
return {
|
|
541
|
-
content: [
|
|
542
|
-
{
|
|
543
|
-
type: "text",
|
|
544
|
-
text: `REFUSED: Cannot start tunnel — this laptop is not signed into Cloudflare or the sign-in has drifted. ${cloudflared.recoveryMessage()}`,
|
|
545
|
-
},
|
|
546
|
-
],
|
|
547
|
-
isError: true,
|
|
548
|
-
};
|
|
549
|
-
}
|
|
550
|
-
// GATE 4: config.yml must exist
|
|
551
|
-
const tunnelState = cloudflared.getPersistedState();
|
|
552
|
-
const configPath = tunnelState?.configPath;
|
|
553
|
-
const credentialsPath = tunnelState?.credentialsPath;
|
|
554
|
-
const tunnelName = tunnelState?.tunnelName ?? tunnelId;
|
|
555
|
-
if (!configPath) {
|
|
556
|
-
return {
|
|
557
|
-
content: [
|
|
558
|
-
{
|
|
559
|
-
type: "text",
|
|
560
|
-
text: `REFUSED: No tunnel configuration found. Run tunnel-create first — it writes the config.yml needed to start the tunnel.`,
|
|
561
|
-
},
|
|
562
|
-
],
|
|
563
|
-
isError: true,
|
|
564
|
-
};
|
|
565
|
-
}
|
|
566
|
-
cloudflared.startTunnel({
|
|
567
|
-
tunnelId,
|
|
568
|
-
tunnelName,
|
|
569
|
-
domain,
|
|
570
|
-
configPath,
|
|
571
|
-
credentialsPath: credentialsPath ?? "",
|
|
572
|
-
});
|
|
573
|
-
const logHint = `~/${brand.configDir}/logs/cloudflared.log`;
|
|
574
|
-
for (let i = 0; i < 10; i++) {
|
|
575
|
-
await new Promise((r) => setTimeout(r, 500));
|
|
576
|
-
const status = await cloudflared.getStatus(domain);
|
|
577
|
-
if (!status.running) {
|
|
578
|
-
return {
|
|
579
|
-
content: [
|
|
580
|
-
{
|
|
581
|
-
type: "text",
|
|
582
|
-
text: `Tunnel process exited immediately after starting. Check ${logHint} for details.`,
|
|
583
|
-
},
|
|
584
|
-
],
|
|
585
|
-
isError: true,
|
|
586
|
-
};
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
const status = await cloudflared.getStatus(domain);
|
|
590
|
-
const verifyUrl = `https://${hostnames[0]}`;
|
|
591
|
-
let verified = false;
|
|
592
|
-
console.error(`[tunnel-enable] verifying url=${verifyUrl}`);
|
|
593
|
-
for (let attempt = 0; attempt < 6; attempt++) {
|
|
594
|
-
if (attempt > 0)
|
|
595
|
-
await new Promise((r) => setTimeout(r, 5000));
|
|
596
|
-
try {
|
|
597
|
-
const res = await fetch(verifyUrl, {
|
|
598
|
-
signal: AbortSignal.timeout(10000),
|
|
599
|
-
redirect: "manual",
|
|
600
|
-
});
|
|
601
|
-
console.error(`[tunnel-enable] verified url=${verifyUrl} status=${res.status}`);
|
|
602
|
-
if (res.status !== 530) {
|
|
603
|
-
verified = true;
|
|
604
|
-
break;
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
catch {
|
|
608
|
-
// network error — retry
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
if (!verified) {
|
|
612
|
-
return {
|
|
613
|
-
content: [
|
|
614
|
-
{
|
|
615
|
-
type: "text",
|
|
616
|
-
text: `Tunnel process is running (PID ${status.pid}) but ${verifyUrl} returned 530 after 30s of retries. Cloudflare's edge cannot reach this tunnel.\n\nCommon causes:\n 1. Stale DNS records pointing to a previous tunnel — run tunnel-create again to fix\n 2. UDP buffer too small for QUIC — check ${logHint}\n 3. Firewall blocking outbound QUIC to Cloudflare's edge\n\nThe tunnel is NOT working. Do not report success.`,
|
|
617
|
-
},
|
|
618
|
-
],
|
|
619
|
-
isError: true,
|
|
620
|
-
};
|
|
621
|
-
}
|
|
622
|
-
const urlLines = hostnames.map((h, i) => {
|
|
623
|
-
const label = i === 0 ? "Admin" : "Public";
|
|
624
|
-
return ` ${label}: https://${h}`;
|
|
625
|
-
}).join("\n");
|
|
626
|
-
const reachableNote = hostnames.length > 1
|
|
627
|
-
? "All URLs are reachable through Cloudflare."
|
|
628
|
-
: "The admin URL is reachable through Cloudflare.";
|
|
629
|
-
return {
|
|
630
|
-
content: [
|
|
631
|
-
{
|
|
632
|
-
type: "text",
|
|
633
|
-
text: `Tunnel started and verified.\n\n PID: ${status.pid}\n${urlLines}\n\n${reachableNote} The tunnel auto-starts on device reboot.`,
|
|
634
|
-
},
|
|
635
|
-
],
|
|
636
|
-
};
|
|
637
|
-
}
|
|
638
|
-
catch (err) {
|
|
639
|
-
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
640
|
-
return {
|
|
641
|
-
content: [{ type: "text", text: err.refusal.message }],
|
|
642
|
-
isError: true,
|
|
643
|
-
};
|
|
644
|
-
}
|
|
645
|
-
return {
|
|
646
|
-
content: [
|
|
647
|
-
{
|
|
648
|
-
type: "text",
|
|
649
|
-
text: `Enable failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
650
|
-
},
|
|
651
|
-
],
|
|
652
|
-
isError: true,
|
|
653
|
-
};
|
|
654
|
-
}
|
|
655
|
-
});
|
|
656
|
-
server.tool("tunnel-disable", "Stop the Cloudflare Tunnel process. Config is preserved for re-enabling.", {}, async () => {
|
|
657
|
-
try {
|
|
658
|
-
cloudflared.stopTunnel();
|
|
659
|
-
return {
|
|
660
|
-
content: [
|
|
661
|
-
{
|
|
662
|
-
type: "text",
|
|
663
|
-
text: "Tunnel stopped. Config preserved — use tunnel-enable to restart.",
|
|
664
|
-
},
|
|
665
|
-
],
|
|
666
|
-
};
|
|
667
|
-
}
|
|
668
|
-
catch (err) {
|
|
669
|
-
return {
|
|
670
|
-
content: [
|
|
671
|
-
{
|
|
672
|
-
type: "text",
|
|
673
|
-
text: `Disable failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
674
|
-
},
|
|
675
|
-
],
|
|
676
|
-
isError: true,
|
|
677
|
-
};
|
|
678
|
-
}
|
|
679
|
-
});
|
|
680
|
-
server.tool("tunnel-add-hostname", "Add an additional address to an existing Cloudflare Tunnel. Refuses pre-flight when the hostname's domain is not on Cloudflare, and refuses post-flight when `cloudflared` routes the address under a different domain (meaning the laptop is signed into the wrong Cloudflare account). Idempotent — safe to call again with the same hostname.", {
|
|
681
|
-
hostname: z
|
|
682
|
-
.string()
|
|
683
|
-
.describe("The address to add (e.g. 'maxy.chat'). The domain must be on the Cloudflare account this laptop is signed into."),
|
|
684
|
-
tunnelId: z
|
|
685
|
-
.string()
|
|
686
|
-
.describe("The tunnel UUID. Get this from tunnel-status."),
|
|
687
|
-
}, async ({ hostname, tunnelId }) => {
|
|
688
|
-
try {
|
|
689
|
-
const route = await cloudflared.routeDnsCli(tunnelId, hostname);
|
|
690
|
-
cloudflared.saveAliasDomain(hostname);
|
|
691
|
-
const routeNote = route.created
|
|
692
|
-
? "created"
|
|
693
|
-
: "already existed";
|
|
694
|
-
return {
|
|
695
|
-
content: [
|
|
696
|
-
{
|
|
697
|
-
type: "text",
|
|
698
|
-
text: `Address configured.\n\n Address: ${hostname}\n Routing: ${routeNote}\n Web server: registered in alias-domains.json (hot-reloaded automatically)\n\nhttps://${hostname} will be active once DNS propagates (1-5 minutes). Run tunnel-status to confirm.`,
|
|
699
|
-
},
|
|
700
|
-
],
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
catch (err) {
|
|
704
|
-
if (err instanceof cloudflared.CloudflareRefusalError) {
|
|
705
|
-
return {
|
|
706
|
-
content: [{ type: "text", text: err.refusal.message }],
|
|
707
|
-
isError: true,
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
return {
|
|
711
|
-
content: [
|
|
712
|
-
{
|
|
713
|
-
type: "text",
|
|
714
|
-
text: `Failed to add address: ${err instanceof Error ? err.message : String(err)}`,
|
|
715
|
-
},
|
|
716
|
-
],
|
|
717
|
-
isError: true,
|
|
718
|
-
};
|
|
719
|
-
}
|
|
720
|
-
});
|
|
721
|
-
server.tool("dns-lookup", "Resolve DNS records for a hostname. Replaces dig/nslookup (not installed on Pi). Supports A, AAAA, CNAME, MX, TXT, NS, and SOA record types.", {
|
|
722
|
-
hostname: z
|
|
723
|
-
.string()
|
|
724
|
-
.describe("The hostname to resolve (e.g. 'admin.maxy.bot')"),
|
|
725
|
-
type: z
|
|
726
|
-
.enum(["A", "AAAA", "CNAME", "MX", "TXT", "NS", "SOA"])
|
|
727
|
-
.optional()
|
|
728
|
-
.describe("Record type (default: A)"),
|
|
729
|
-
nameserver: z
|
|
730
|
-
.string()
|
|
731
|
-
.optional()
|
|
732
|
-
.describe("Custom nameserver IP (default: Cloudflare 1.1.1.1 + Google 8.8.8.8)"),
|
|
733
|
-
}, async ({ hostname, type, nameserver }) => {
|
|
734
|
-
try {
|
|
735
|
-
const resolver = new Resolver();
|
|
736
|
-
resolver.setServers(nameserver ? [nameserver] : ["1.1.1.1", "8.8.8.8"]);
|
|
737
|
-
const recordType = type ?? "A";
|
|
738
|
-
const results = {
|
|
739
|
-
hostname,
|
|
740
|
-
type: recordType,
|
|
741
|
-
};
|
|
742
|
-
switch (recordType) {
|
|
743
|
-
case "A": {
|
|
744
|
-
results.addresses = await resolver.resolve4(hostname);
|
|
745
|
-
break;
|
|
746
|
-
}
|
|
747
|
-
case "AAAA": {
|
|
748
|
-
results.addresses = await resolver.resolve6(hostname);
|
|
749
|
-
break;
|
|
750
|
-
}
|
|
751
|
-
case "CNAME": {
|
|
752
|
-
try {
|
|
753
|
-
results.cnames = await resolver.resolveCname(hostname);
|
|
754
|
-
}
|
|
755
|
-
catch (cnameErr) {
|
|
756
|
-
const cnameCode = cnameErr.code;
|
|
757
|
-
if (cnameCode === "ENODATA") {
|
|
758
|
-
try {
|
|
759
|
-
const aRecords = await resolver.resolve4(hostname);
|
|
760
|
-
results.note = "CNAME lookup returned ENODATA — record is likely flattened to A";
|
|
761
|
-
results.addresses = aRecords;
|
|
762
|
-
}
|
|
763
|
-
catch {
|
|
764
|
-
throw cnameErr;
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
else {
|
|
768
|
-
throw cnameErr;
|
|
769
|
-
}
|
|
770
|
-
}
|
|
771
|
-
break;
|
|
772
|
-
}
|
|
773
|
-
case "MX": {
|
|
774
|
-
results.exchanges = await resolver.resolveMx(hostname);
|
|
775
|
-
break;
|
|
776
|
-
}
|
|
777
|
-
case "TXT": {
|
|
778
|
-
results.records = await resolver.resolveTxt(hostname);
|
|
779
|
-
break;
|
|
780
|
-
}
|
|
781
|
-
case "NS": {
|
|
782
|
-
results.nameservers = await resolver.resolveNs(hostname);
|
|
783
|
-
break;
|
|
784
|
-
}
|
|
785
|
-
case "SOA": {
|
|
786
|
-
results.soa = await resolver.resolveSoa(hostname);
|
|
787
|
-
break;
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
return {
|
|
791
|
-
content: [
|
|
792
|
-
{ type: "text", text: JSON.stringify(results, null, 2) },
|
|
793
|
-
],
|
|
794
|
-
};
|
|
795
|
-
}
|
|
796
|
-
catch (err) {
|
|
797
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
798
|
-
const code = err.code;
|
|
799
|
-
return {
|
|
800
|
-
content: [
|
|
801
|
-
{
|
|
802
|
-
type: "text",
|
|
803
|
-
text: `DNS lookup failed: ${msg}${code ? ` (${code})` : ""}\n\nCommon causes:\n- ENOTFOUND: hostname does not exist\n- ENODATA: no records of this type for hostname\n- ETIMEOUT: nameserver unreachable`,
|
|
804
|
-
},
|
|
805
|
-
],
|
|
806
|
-
isError: true,
|
|
807
|
-
};
|
|
808
|
-
}
|
|
809
|
-
});
|
|
810
|
-
// ===================================================================
|
|
811
|
-
// Setup orchestrator — deterministic multi-step flow (Task 547)
|
|
812
|
-
//
|
|
813
|
-
// `cloudflare-setup-run` is the single operator-facing entry point for
|
|
814
|
-
// tunnel setup. It drives the full login → create → enable → verify
|
|
815
|
-
// sequence in code, using the enum output of `tunnel-status` to decide
|
|
816
|
-
// each transition. The LLM never picks between tunnel-login/create/enable
|
|
817
|
-
// during an active setup — those tools are removed from its menu by the
|
|
818
|
-
// tool-surface filter (platform/ui/app/lib/tool-surface-filter.ts) while
|
|
819
|
-
// `phase !== 'idle' && phase !== 'healthy' && phase !== 'unhealthy'`.
|
|
820
|
-
//
|
|
821
|
-
// See platform/plugins/cloudflare/mcp/src/lib/setup-orchestrator.ts for
|
|
822
|
-
// the state machine.
|
|
823
|
-
// ===================================================================
|
|
824
|
-
server.tool("cloudflare-setup-run", "Run the full Cloudflare tunnel setup to completion. Single operator-facing entry point — handles sign-in, tunnel creation, DNS routing, and verification deterministically. Every sub-step is chosen in code, not by the LLM. Pass `domain` on the first call. Pass `adminSubdomain` (and optional `publicSubdomain`) once sign-in is complete. Pass `confirmed: true` to acknowledge the operator has finished a device-bound step (signed in, switched accounts). Re-entrant — safe to invoke repeatedly; persisted state resumes at the correct phase.", {
|
|
825
|
-
domain: z
|
|
826
|
-
.string()
|
|
827
|
-
.optional()
|
|
828
|
-
.describe("The bare domain (e.g. 'maxy.bot'). Required on the first call; ignored afterwards."),
|
|
829
|
-
adminSubdomain: z
|
|
830
|
-
.string()
|
|
831
|
-
.optional()
|
|
832
|
-
.describe("Admin subdomain label (e.g. 'admin' → admin.{domain}). Provide when the tool asks for subdomains."),
|
|
833
|
-
publicSubdomain: z
|
|
834
|
-
.string()
|
|
835
|
-
.optional()
|
|
836
|
-
.describe("Optional public chat subdomain label. Leave unset to skip the public endpoint."),
|
|
837
|
-
confirmed: z
|
|
838
|
-
.boolean()
|
|
839
|
-
.optional()
|
|
840
|
-
.describe("Set to true to signal the operator has completed a device-bound action (signed in, switched Cloudflare accounts). Ignored in phases where no such action is pending."),
|
|
841
|
-
}, async ({ domain, adminSubdomain, publicSubdomain, confirmed }) => {
|
|
842
|
-
try {
|
|
843
|
-
const result = await orchestrator.runOrchestrator({
|
|
844
|
-
domain,
|
|
845
|
-
adminSubdomain,
|
|
846
|
-
publicSubdomain,
|
|
847
|
-
confirmed,
|
|
848
|
-
});
|
|
849
|
-
return {
|
|
850
|
-
content: [{ type: "text", text: result.text }],
|
|
851
|
-
isError: !result.healthy && (result.phase === "unhealthy"),
|
|
852
|
-
};
|
|
853
|
-
}
|
|
854
|
-
catch (err) {
|
|
855
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
856
|
-
console.error(`[cloudflare:setup-run:threw] ${msg}`);
|
|
857
|
-
return {
|
|
858
|
-
content: [{ type: "text", text: `Tunnel setup encountered an unexpected error: ${msg}` }],
|
|
859
|
-
isError: true,
|
|
860
|
-
};
|
|
861
|
-
}
|
|
862
|
-
});
|
|
863
|
-
server.tool("cloudflare-setup-status", "Report the current tunnel-setup orchestrator phase without advancing it. Use this for diagnostics — to answer 'where is the setup flow right now?' — not as a routine pre-check before `cloudflare-setup-run` (the run tool reads and reconciles state on every call).", {}, async () => {
|
|
864
|
-
const state = orchestrator.readOrchestratorState();
|
|
865
|
-
return {
|
|
866
|
-
content: [
|
|
867
|
-
{
|
|
868
|
-
type: "text",
|
|
869
|
-
text: JSON.stringify({
|
|
870
|
-
phase: state.phase,
|
|
871
|
-
domain: state.domain,
|
|
872
|
-
adminSubdomain: state.adminSubdomain,
|
|
873
|
-
publicSubdomain: state.publicSubdomain,
|
|
874
|
-
tunnelId: state.tunnelId,
|
|
875
|
-
phaseEnteredAt: state.phaseEnteredAt,
|
|
876
|
-
unhealthyReason: state.unhealthyReason,
|
|
877
|
-
setupActive: orchestrator.isSetupActive(state),
|
|
878
|
-
}, null, 2),
|
|
879
|
-
},
|
|
880
|
-
],
|
|
881
|
-
};
|
|
19
|
+
version: "0.4.0",
|
|
882
20
|
});
|
|
883
21
|
async function main() {
|
|
884
22
|
const transport = new StdioServerTransport();
|