@moor-sh/mcp 0.1.0 → 0.2.1

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 +101 -0
  2. package/package.json +3 -3
  3. package/src/index.ts +435 -0
package/README.md ADDED
@@ -0,0 +1,101 @@
1
+ # @moor-sh/mcp
2
+
3
+ MCP server for [moor](https://github.com/caiopizzol/moor) - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects through standard MCP tools. Talks to moor's HTTP API; no repo clone needed.
4
+
5
+ Requires [Bun](https://bun.sh) on the machine running the MCP client. `bunx` fetches and runs `@moor-sh/mcp` directly as the client's MCP subprocess.
6
+
7
+ ## Setup
8
+
9
+ The easiest path is the `moor mcp config` subcommand from [`@moor-sh/cli`](https://www.npmjs.com/package/@moor-sh/cli):
10
+
11
+ ```bash
12
+ bunx @moor-sh/cli mcp config --client claude # or claude-code, codex
13
+ ```
14
+
15
+ It prints a config snippet you paste into your MCP client. Or configure manually:
16
+
17
+ ### Claude Code (`~/.claude.json`)
18
+
19
+ ```json
20
+ {
21
+ "mcpServers": {
22
+ "moor": {
23
+ "command": "bunx",
24
+ "args": ["@moor-sh/mcp"],
25
+ "env": {
26
+ "MOOR_URL": "http://127.0.0.1:8080",
27
+ "MOOR_API_KEY": "your-api-key"
28
+ }
29
+ }
30
+ }
31
+ }
32
+ ```
33
+
34
+ ### Codex (`~/.codex/config.toml`)
35
+
36
+ ```toml
37
+ [mcp_servers.moor]
38
+ command = "bunx"
39
+ args = ["@moor-sh/mcp"]
40
+
41
+ [mcp_servers.moor.env]
42
+ MOOR_URL = "http://127.0.0.1:8080"
43
+ MOOR_API_KEY = "your-api-key"
44
+ ```
45
+
46
+ For a moor on the same machine as the client, change `MOOR_URL` to `http://localhost:3000`.
47
+
48
+ ## SSH tunnel for remote moor
49
+
50
+ By default moor's admin is bound to `127.0.0.1:3000` on the server. The MCP client connects to whatever `MOOR_URL` resolves to on the client machine, so for a remote moor, open a tunnel from your laptop:
51
+
52
+ ```bash
53
+ ssh -fNL 8080:127.0.0.1:3000 your-server
54
+ ```
55
+
56
+ `MOOR_URL=http://127.0.0.1:8080` matches the laptop side of that tunnel. The tunnel must stay up while the MCP client is in use. For a tunnel that survives sleep and reboots, see the [self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md#persistent-tunnel).
57
+
58
+ ## API key
59
+
60
+ `MOOR_API_KEY` grants admin-equivalent control of the moor host. See the [self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md#api-keys) for how to generate, verify, and rotate it.
61
+
62
+ ## Smoke test
63
+
64
+ Before relying on the integration:
65
+
66
+ ```bash
67
+ MOOR_URL=http://127.0.0.1:8080 MOOR_API_KEY=your-api-key bunx @moor-sh/mcp < /dev/null
68
+ ```
69
+
70
+ Exit 0 with no output means the MCP connected, authenticated, and shut down cleanly when stdin closed. Any stderr line plus non-zero exit tells you what's wrong:
71
+
72
+ - `Cannot reach moor at ...` - URL unreachable or tunnel is down.
73
+ - `Authentication failed` - `MOOR_API_KEY` doesn't match the server.
74
+ - `moor at ... returned 503` - admin password not configured on the moor server.
75
+
76
+ ## Tools
77
+
78
+ The MCP server exposes:
79
+
80
+ - `moor_status` - list all projects with status, source, and domain
81
+ - `moor_logs` - get recent container logs for a project (with tail length)
82
+ - `moor_rebuild` - rebuild a project from source
83
+ - `moor_restart` - stop and start a project's container
84
+ - `moor_exec` - run a command inside a project's container
85
+ - `moor_env_list` - list environment variables for a project
86
+ - `moor_env_set` - set environment variables and restart
87
+ - `moor_stats` - host CPU / memory / disk / container counts
88
+
89
+ ## Transport
90
+
91
+ Stdio only. The MCP client launches `bunx @moor-sh/mcp` as a subprocess and talks over stdin/stdout. HTTP transport is [tracked](https://github.com/caiopizzol/moor/issues/17) but not yet shipped.
92
+
93
+ ## Links
94
+
95
+ - [moor repo](https://github.com/caiopizzol/moor) - main project
96
+ - [Self-hosting guide](https://github.com/caiopizzol/moor/blob/main/docs/self-hosting.md) - first boot, API keys, admin domain
97
+ - [`@moor-sh/cli`](https://www.npmjs.com/package/@moor-sh/cli) - command-line interface with `moor mcp config` helper
98
+
99
+ ## License
100
+
101
+ MIT.
package/package.json CHANGED
@@ -1,14 +1,14 @@
1
1
  {
2
2
  "name": "@moor-sh/mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "MCP server for moor - lets AI agents (Claude Code, Cursor, etc.) manage your moor projects via the moor HTTP API.",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
8
- "url": "https://github.com/caiopizzol/moor.git",
8
+ "url": "git+https://github.com/caiopizzol/moor.git",
9
9
  "directory": "packages/mcp"
10
10
  },
11
- "homepage": "https://github.com/caiopizzol/moor#mcp-server",
11
+ "homepage": "https://github.com/caiopizzol/moor/tree/main/packages/mcp",
12
12
  "bugs": "https://github.com/caiopizzol/moor/issues",
13
13
  "keywords": [
14
14
  "moor",
package/src/index.ts CHANGED
@@ -73,6 +73,13 @@ async function apiPut(path: string, body: unknown) {
73
73
  });
74
74
  }
75
75
 
76
+ async function apiDelete(path: string) {
77
+ return fetch(`${baseUrl}${path}`, {
78
+ method: "DELETE",
79
+ headers: headers(),
80
+ });
81
+ }
82
+
76
83
  type Project = {
77
84
  id: number;
78
85
  name: string;
@@ -127,6 +134,106 @@ async function readSSE(res: Response): Promise<{ logs: string; error?: string }>
127
134
  return { logs, error };
128
135
  }
129
136
 
137
+ // --- Validators ---
138
+
139
+ /** Validate that a string is a github.com URL. Throws with a clear message on failure.
140
+ * Stricter than apps/api/routes/docker.ts:validateGithubUrl, which accepts any host
141
+ * ending in "github.com" (so "evilgithub.com" slips through). MCP rejects that and
142
+ * surfaces the error at create/update time, not at first build/run. */
143
+ function validateGithubUrl(url: string): void {
144
+ let host: string;
145
+ try {
146
+ host = new URL(url).hostname;
147
+ } catch {
148
+ throw new Error(`github_url is not a valid URL: ${url}`);
149
+ }
150
+ if (host !== "github.com" && !host.endsWith(".github.com")) {
151
+ throw new Error(`github_url must be a github.com URL (got hostname "${host}")`);
152
+ }
153
+ }
154
+
155
+ /** Validate a 5-field crontab schedule against what apps/api/cron.ts can actually execute.
156
+ * Stricter than the scheduler's permissive parser: the scheduler silently never fires
157
+ * on bad input, so MCP rejects up-front. Returns an error string or null. */
158
+ const CRON_FIELDS: ReadonlyArray<{ name: string; min: number; max: number }> = [
159
+ { name: "minute", min: 0, max: 59 },
160
+ { name: "hour", min: 0, max: 23 },
161
+ { name: "day-of-month", min: 1, max: 31 },
162
+ { name: "month", min: 1, max: 12 },
163
+ { name: "day-of-week", min: 0, max: 6 }, // 0=Sunday; scheduler does not translate 7
164
+ ];
165
+
166
+ // Whitelist each comma-separated part against one of the canonical forms below.
167
+ // Anything else (empty parts, leading "-", bare "N/S", "/S", stray characters) is
168
+ // rejected. The scheduler at apps/api/cron.ts silently ignores or mis-parses these
169
+ // inputs, so the validator must be strict where the scheduler is loose.
170
+ const CRON_PART_PATTERNS = [
171
+ /^\*$/, // *
172
+ /^(\d+)$/, // N
173
+ /^(\d+)-(\d+)$/, // A-B
174
+ /^\*\/(\d+)$/, // */S
175
+ /^(\d+)-(\d+)\/(\d+)$/, // A-B/S
176
+ ];
177
+
178
+ function validateCronField(field: string, min: number, max: number, name: string): string | null {
179
+ if (field === "*") return null;
180
+ if (/[?LW#]/i.test(field)) return `${name}: ?, L, W, # are not supported`;
181
+ if (/[a-zA-Z]/.test(field))
182
+ return `${name}: month/day names are not supported, use numeric values`;
183
+
184
+ for (const part of field.split(",")) {
185
+ if (part === "") return `${name}: empty list element`;
186
+
187
+ const match = CRON_PART_PATTERNS.map((re) => part.match(re)).find((m) => m !== null);
188
+ if (!match) return `${name}: invalid expression "${part}"`;
189
+
190
+ // Validate captured numbers against per-field bounds and step positivity.
191
+ // Capture layout depends on which pattern matched, identified by length.
192
+ const groups = match.slice(1);
193
+ if (groups.length === 1 && match[0].startsWith("*/")) {
194
+ // */S
195
+ const step = Number(groups[0]);
196
+ if (step <= 0) return `${name}: step must be a positive integer (got "${groups[0]}")`;
197
+ } else if (groups.length === 1) {
198
+ // N
199
+ const n = Number(groups[0]);
200
+ if (n < min || n > max) return `${name}: ${n} out of bounds [${min}-${max}]`;
201
+ } else if (groups.length === 2) {
202
+ // A-B
203
+ const a = Number(groups[0]);
204
+ const b = Number(groups[1]);
205
+ if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
206
+ if (a > b) return `${name}: range ${a}-${b} is descending`;
207
+ } else if (groups.length === 3) {
208
+ // A-B/S
209
+ const a = Number(groups[0]);
210
+ const b = Number(groups[1]);
211
+ const step = Number(groups[2]);
212
+ if (a < min || b > max) return `${name}: range ${a}-${b} out of bounds [${min}-${max}]`;
213
+ if (a > b) return `${name}: range ${a}-${b} is descending`;
214
+ if (step <= 0) return `${name}: step must be a positive integer (got "${groups[2]}")`;
215
+ }
216
+ }
217
+ return null;
218
+ }
219
+
220
+ function validateCronSchedule(schedule: string): string | null {
221
+ const parts = schedule.trim().split(/\s+/);
222
+ if (parts.length !== 5) {
223
+ return `schedule must have exactly 5 space-separated fields (got ${parts.length})`;
224
+ }
225
+ for (let i = 0; i < 5; i++) {
226
+ const err = validateCronField(
227
+ parts[i],
228
+ CRON_FIELDS[i].min,
229
+ CRON_FIELDS[i].max,
230
+ CRON_FIELDS[i].name,
231
+ );
232
+ if (err) return err;
233
+ }
234
+ return null;
235
+ }
236
+
130
237
  // --- MCP Server ---
131
238
 
132
239
  const server = new McpServer({
@@ -340,6 +447,334 @@ server.registerTool(
340
447
  },
341
448
  );
342
449
 
450
+ server.registerTool(
451
+ "moor_project_get",
452
+ {
453
+ title: "Get Project",
454
+ description:
455
+ "Returns the full record for a project (source, branch, dockerfile, domain, status, container id, restart policy).",
456
+ inputSchema: z.object({
457
+ project: z.string().describe("Project name or ID"),
458
+ }),
459
+ },
460
+ async ({ project }) => {
461
+ const p = await resolveProject(project);
462
+ return { content: [{ type: "text", text: JSON.stringify(p, null, 2) }] };
463
+ },
464
+ );
465
+
466
+ server.registerTool(
467
+ "moor_project_create",
468
+ {
469
+ title: "Create Project",
470
+ description:
471
+ "Creates a new project. Provide exactly one of github_url or docker_image. Does not build or start; call moor_rebuild (or moor_deploy in a future release) to bring it up.",
472
+ inputSchema: z.object({
473
+ name: z
474
+ .string()
475
+ .regex(
476
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
477
+ "name must start with an alphanumeric character; allowed chars: a-z, A-Z, 0-9, _, -",
478
+ )
479
+ .describe("Project name (used as the container name suffix: moor-<name>)"),
480
+ github_url: z
481
+ .string()
482
+ .optional()
483
+ .describe("github.com URL; mutually exclusive with docker_image"),
484
+ docker_image: z
485
+ .string()
486
+ .optional()
487
+ .describe("Docker image reference (e.g. nginx:latest); mutually exclusive with github_url"),
488
+ branch: z.string().optional().describe("Git branch (default: main, for github_url projects)"),
489
+ dockerfile: z
490
+ .string()
491
+ .optional()
492
+ .describe("Dockerfile path within the repo (default: Dockerfile)"),
493
+ domain: z.string().optional().describe("Public domain to route to this container via Caddy"),
494
+ domain_port: z
495
+ .number()
496
+ .int()
497
+ .positive()
498
+ .optional()
499
+ .describe("Container port Caddy should forward to (required if domain is set)"),
500
+ restart_policy: z
501
+ .enum(["no", "on-failure", "always", "unless-stopped"])
502
+ .optional()
503
+ .describe("Docker restart policy (default: unless-stopped)"),
504
+ }),
505
+ },
506
+ async (input) => {
507
+ const sources = (input.github_url ? 1 : 0) + (input.docker_image ? 1 : 0);
508
+ if (sources !== 1) {
509
+ throw new Error("Provide exactly one of github_url or docker_image");
510
+ }
511
+ if (input.github_url) validateGithubUrl(input.github_url);
512
+
513
+ const res = await apiPost("/api/projects", input);
514
+ if (!res.ok) throw new Error(`Failed to create project: ${await res.text()}`);
515
+ const project = await res.json();
516
+ return { content: [{ type: "text", text: JSON.stringify(project, null, 2) }] };
517
+ },
518
+ );
519
+
520
+ server.registerTool(
521
+ "moor_project_update",
522
+ {
523
+ title: "Update Project",
524
+ description:
525
+ "Updates project metadata. Does NOT rebuild or restart the container. Domain or domain_port changes apply to Caddy immediately.",
526
+ inputSchema: z.object({
527
+ project: z.string().describe("Project name or ID to update"),
528
+ name: z
529
+ .string()
530
+ .regex(
531
+ /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/,
532
+ "name must start alphanumeric; allowed: a-z A-Z 0-9 _ -",
533
+ )
534
+ .optional(),
535
+ github_url: z.string().optional(),
536
+ docker_image: z.string().optional(),
537
+ branch: z.string().optional(),
538
+ dockerfile: z.string().optional(),
539
+ domain: z.string().optional(),
540
+ domain_port: z.number().int().positive().optional(),
541
+ restart_policy: z.enum(["no", "on-failure", "always", "unless-stopped"]).optional(),
542
+ }),
543
+ },
544
+ async (input) => {
545
+ const { project, ...updates } = input;
546
+ if (Object.keys(updates).length === 0) {
547
+ throw new Error("Provide at least one field to update");
548
+ }
549
+ if (updates.github_url && updates.docker_image) {
550
+ throw new Error("Cannot set both github_url and docker_image in the same update");
551
+ }
552
+ if (updates.github_url) validateGithubUrl(updates.github_url);
553
+
554
+ const p = await resolveProject(project);
555
+ const res = await apiPut(`/api/projects/${p.id}`, updates);
556
+ if (!res.ok) throw new Error(`Failed to update project: ${await res.text()}`);
557
+ const updated = await res.json();
558
+ return { content: [{ type: "text", text: JSON.stringify(updated, null, 2) }] };
559
+ },
560
+ );
561
+
562
+ server.registerTool(
563
+ "moor_project_delete",
564
+ {
565
+ title: "Delete Project",
566
+ description:
567
+ "Stops and removes the container, then deletes the project record. Requires confirm_name to match the resolved project name exactly. Irreversible.",
568
+ inputSchema: z.object({
569
+ project: z.string().describe("Project name or ID to delete"),
570
+ confirm_name: z
571
+ .string()
572
+ .describe(
573
+ "Must equal the resolved project's name. Guards against deleting the wrong project.",
574
+ ),
575
+ }),
576
+ },
577
+ async ({ project, confirm_name }) => {
578
+ const p = await resolveProject(project);
579
+ if (confirm_name !== p.name) {
580
+ throw new Error(
581
+ `confirm_name "${confirm_name}" does not match resolved project name "${p.name}". Refusing to delete.`,
582
+ );
583
+ }
584
+ const res = await apiDelete(`/api/projects/${p.id}`);
585
+ if (!res.ok) throw new Error(`Failed to delete project: ${await res.text()}`);
586
+ return { content: [{ type: "text", text: `Deleted project ${p.name} (id=${p.id}).` }] };
587
+ },
588
+ );
589
+
590
+ server.registerTool(
591
+ "moor_cron_create",
592
+ {
593
+ title: "Create Cron",
594
+ description:
595
+ "Creates a cron schedule on a project. Schedule is a 5-field crontab string with numeric values only (no jan/sun/etc.). Day-of-week uses 0=Sunday through 6=Saturday; 7 is not accepted.",
596
+ inputSchema: z.object({
597
+ project: z.string().describe("Project name or ID"),
598
+ name: z.string().min(1).describe("Human-readable name for the cron"),
599
+ schedule: z.string().describe('5-field crontab, e.g. "0 3 * * *" for 03:00 daily'),
600
+ command: z.string().min(1).describe("Shell command to run inside the project's container"),
601
+ }),
602
+ },
603
+ async ({ project, name, schedule, command }) => {
604
+ const err = validateCronSchedule(schedule);
605
+ if (err) throw new Error(`Invalid schedule: ${err}`);
606
+ const p = await resolveProject(project);
607
+ const res = await apiPost(`/api/projects/${p.id}/crons`, { name, schedule, command });
608
+ if (!res.ok) throw new Error(`Failed to create cron: ${await res.text()}`);
609
+ const cron = await res.json();
610
+ return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
611
+ },
612
+ );
613
+
614
+ server.registerTool(
615
+ "moor_cron_update",
616
+ {
617
+ title: "Update Cron",
618
+ description: "Updates a cron's fields by id. Schedule is validated if provided.",
619
+ inputSchema: z.object({
620
+ cron_id: z.number().int().positive().describe("Cron ID"),
621
+ name: z.string().min(1).optional(),
622
+ schedule: z.string().optional(),
623
+ command: z.string().min(1).optional(),
624
+ enabled: z.boolean().optional().describe("Enable or disable the cron"),
625
+ }),
626
+ },
627
+ async ({ cron_id, name, schedule, command, enabled }) => {
628
+ if (schedule !== undefined) {
629
+ const err = validateCronSchedule(schedule);
630
+ if (err) throw new Error(`Invalid schedule: ${err}`);
631
+ }
632
+ const body: Record<string, unknown> = {};
633
+ if (name !== undefined) body.name = name;
634
+ if (schedule !== undefined) body.schedule = schedule;
635
+ if (command !== undefined) body.command = command;
636
+ if (enabled !== undefined) body.enabled = enabled ? 1 : 0;
637
+ if (Object.keys(body).length === 0) {
638
+ throw new Error("Provide at least one field to update");
639
+ }
640
+ const res = await apiPut(`/api/crons/${cron_id}`, body);
641
+ if (!res.ok) throw new Error(`Failed to update cron: ${await res.text()}`);
642
+ const cron = await res.json();
643
+ return { content: [{ type: "text", text: JSON.stringify(cron, null, 2) }] };
644
+ },
645
+ );
646
+
647
+ server.registerTool(
648
+ "moor_cron_delete",
649
+ {
650
+ title: "Delete Cron",
651
+ description: "Deletes a cron by id.",
652
+ inputSchema: z.object({
653
+ cron_id: z.number().int().positive().describe("Cron ID"),
654
+ }),
655
+ },
656
+ async ({ cron_id }) => {
657
+ const res = await apiDelete(`/api/crons/${cron_id}`);
658
+ if (!res.ok) throw new Error(`Failed to delete cron: ${await res.text()}`);
659
+ // API returns 204 whether or not the row existed; phrase the response so it
660
+ // doesn't claim a row was removed when it might already have been gone.
661
+ return { content: [{ type: "text", text: `Deletion requested for cron ${cron_id}.` }] };
662
+ },
663
+ );
664
+
665
+ server.registerTool(
666
+ "moor_cron_run",
667
+ {
668
+ title: "Run Cron Now",
669
+ description:
670
+ "Triggers a cron to run immediately. Requires the project's container to be running.",
671
+ inputSchema: z.object({
672
+ cron_id: z.number().int().positive().describe("Cron ID"),
673
+ }),
674
+ },
675
+ async ({ cron_id }) => {
676
+ const res = await apiPost(`/api/crons/${cron_id}/run`);
677
+ if (!res.ok) {
678
+ const text = await res.text();
679
+ let message = text;
680
+ try {
681
+ const parsed = JSON.parse(text);
682
+ if (parsed?.error) message = parsed.error;
683
+ } catch {
684
+ // Not JSON; use raw text
685
+ }
686
+ throw new Error(message);
687
+ }
688
+ return { content: [{ type: "text", text: `Triggered cron ${cron_id}.` }] };
689
+ },
690
+ );
691
+
692
+ server.registerTool(
693
+ "moor_env_delete",
694
+ {
695
+ title: "Delete Environment Variables",
696
+ description:
697
+ "Removes one or more environment variables from a project. Restarts the container only if at least one key was actually deleted AND the project was running.",
698
+ inputSchema: z.object({
699
+ project: z.string().describe("Project name or ID"),
700
+ keys: z.array(z.string().min(1)).min(1).describe("Env var keys to remove"),
701
+ }),
702
+ },
703
+ async ({ project, keys }) => {
704
+ const p = await resolveProject(project);
705
+
706
+ const existingRes = await apiGet(`/api/projects/${p.id}/envs`);
707
+ if (!existingRes.ok) throw new Error(`Failed to get envs: ${existingRes.status}`);
708
+ const existing = (await existingRes.json()) as { key: string; value: string }[];
709
+ const existingKeys = new Set(existing.map((v) => v.key));
710
+
711
+ const toDelete = keys.filter((k) => existingKeys.has(k));
712
+ const missing = keys.filter((k) => !existingKeys.has(k));
713
+
714
+ if (toDelete.length === 0) {
715
+ const existingList = [...existingKeys].sort().join(", ") || "(none)";
716
+ return {
717
+ content: [
718
+ {
719
+ type: "text",
720
+ text: `No matching keys on ${p.name}. Existing keys: ${existingList}`,
721
+ },
722
+ ],
723
+ };
724
+ }
725
+
726
+ for (const key of toDelete) {
727
+ const res = await apiDelete(`/api/projects/${p.id}/envs/${encodeURIComponent(key)}`);
728
+ if (!res.ok) throw new Error(`Failed to delete ${key}: ${await res.text()}`);
729
+ }
730
+
731
+ let text = `Deleted ${toDelete.join(", ")} from ${p.name}.`;
732
+ if (missing.length > 0) text += ` (Not present: ${missing.join(", ")}.)`;
733
+
734
+ if (p.status === "running") {
735
+ await apiPost(`/api/projects/${p.id}/stop`);
736
+ const startRes = await apiPost(`/api/projects/${p.id}/start`);
737
+ if (!startRes.ok) {
738
+ throw new Error(`Deleted vars but failed to restart: ${await startRes.text()}`);
739
+ }
740
+ text += " Container restarted.";
741
+ }
742
+
743
+ return { content: [{ type: "text", text }] };
744
+ },
745
+ );
746
+
747
+ server.registerTool(
748
+ "moor_dns_check",
749
+ {
750
+ title: "Check Domain DNS",
751
+ description:
752
+ "Resolves a domain's A record and reports whether it matches the server's public IP. Useful before pointing a project's domain at the server.",
753
+ inputSchema: z.object({
754
+ domain: z.string().min(1).describe("Domain to check, e.g. app.example.com"),
755
+ }),
756
+ },
757
+ async ({ domain }) => {
758
+ const res = await apiPost("/api/dns-check", { domain });
759
+ if (!res.ok) throw new Error(`Failed: ${await res.text()}`);
760
+ const data = (await res.json()) as {
761
+ resolves: boolean;
762
+ ip: string | null;
763
+ serverIp: string | null;
764
+ };
765
+ const lines = [
766
+ `Domain: ${domain}`,
767
+ `Resolves: ${data.resolves ? "yes" : "no"}`,
768
+ `Resolved IP: ${data.ip ?? "(none)"}`,
769
+ `Server IP: ${data.serverIp ?? "(unknown)"}`,
770
+ ];
771
+ if (data.ip && data.serverIp) {
772
+ lines.push(`Match: ${data.ip === data.serverIp ? "yes" : "no"}`);
773
+ }
774
+ return { content: [{ type: "text", text: lines.join("\n") }] };
775
+ },
776
+ );
777
+
343
778
  // --- Start ---
344
779
 
345
780
  const transport = new StdioServerTransport();