@shipstatic/mcp 1.0.0-beta.1 → 1.0.0-beta.10

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/dist/server.js CHANGED
@@ -1,226 +1,59 @@
1
- import { createRequire } from 'node:module';
2
1
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
- import { IDEMPOTENCY_KEY_CONSTRAINTS, LABEL_CONSTRAINTS, PASSWORD_CONSTRAINTS, } from '@shipstatic/ship';
2
+ import { DeploymentVia } from '@shipstatic/types';
4
3
  import { z } from 'zod';
5
4
  import { call } from './call.js';
6
- const { version } = createRequire(import.meta.url)('../package.json');
7
- const OPEN_WORLD = { openWorldHint: true };
8
- const READ = {
9
- readOnlyHint: true,
10
- destructiveHint: false,
11
- idempotentHint: true,
12
- ...OPEN_WORLD,
13
- };
14
- /**
15
- * Deploys carry no `idempotentHint`, and that stays true now that
16
- * `idempotencyKey` exists. The annotation is a STATIC per-tool claim; the
17
- * property it would assert is per-CALL — true only when the caller supplies a
18
- * key, false for the keyless caller, who is the common one. Advertising it
19
- * would tell every agent that any retry is free, which is exactly wrong for
20
- * the majority. An annotation an agent trusts wrongly is worse than one it
21
- * never reads.
22
- */
23
- const CREATE = { readOnlyHint: false, destructiveHint: false, ...OPEN_WORLD };
24
- const WRITE = {
25
- readOnlyHint: false,
26
- destructiveHint: false,
27
- idempotentHint: true,
28
- ...OPEN_WORLD,
29
- };
30
- const DESTRUCTIVE = {
31
- readOnlyHint: false,
32
- destructiveHint: true,
33
- idempotentHint: true,
34
- ...OPEN_WORLD,
35
- };
36
- /**
37
- * The pagination surface, shared by every list tool because it is one
38
- * contract, not two. A list answers `{<collection>, cursor}` and nothing
39
- * else — `cursor` carries the whole has-more signal and is null on the last
40
- * page, so there is no `total` to ask for and no has-more boolean.
41
- *
42
- * No upper bound is stated here on purpose. The API clamps an unusable
43
- * `limit` server-side and owns that number; restating a cap in the tool
44
- * schema would give one fact two owners and let them drift. `min(1)` is not
45
- * a cap — it rejects a value that could never mean anything.
46
- */
47
- const PAGINATION_INPUT = {
48
- limit: z
49
- .number()
50
- .int()
51
- .min(1)
52
- .optional()
53
- .describe('Maximum number of items to return in one page. Omit for the server default.'),
54
- cursor: z
55
- .string()
56
- .optional()
57
- .describe("Opaque position from the previous response's `cursor` field; omit for the first page."),
58
- };
59
- /** Appended to every list tool's description — the paging contract, stated once. */
60
- const PAGING_NOTE = " The response's `cursor` is null on the last page; pass it back as `cursor` to fetch the next.";
61
- const INSTRUCTIONS = `ShipStatic deploys static websites instantly. Free, no account required.
5
+ import { registerAccountTools } from './tools.js';
6
+ import { ANNOTATIONS, DESCRIPTION_BLOCKS, INSTRUCTION_BLOCKS, PARAM_DESCRIPTIONS, PUBLIC_EXPIRY, SERVER_NAME, UPLOAD_TOOL_NAME, } from './vocabulary.js';
7
+ // Destructured so the fifteen registrations below read as they always have.
8
+ // The definitions live in `vocabulary.ts` because the hosted transport speaks
9
+ // the same ones — that file records what is shared, what is not, and why.
10
+ const { CREATE } = ANNOTATIONS;
11
+ const B = INSTRUCTION_BLOCKS;
12
+ const D = DESCRIPTION_BLOCKS;
13
+ // Composed from the shared blocks plus the two sentences that are genuinely
14
+ // stdio's: how files arrive (a filesystem path) and how a caller
15
+ // authenticates (`SHIP_TOKEN`). The hosted transport composes the same blocks
16
+ // around its own two.
17
+ const INSTRUCTIONS = `${B.opening}
62
18
 
63
- To deploy: call deployments_upload with the build output directory path. The site is live immediately. To make the site private, pass \`password\` — visitors must unlock before viewing, including on any custom domains pointing at it.
19
+ To deploy: call ${UPLOAD_TOOL_NAME} with the build output directory path. ${B.liveAndPassword}
64
20
 
65
- Without SHIP_TOKEN, deployments are public and expire in 3 days. The response includes a claim URL — always show the deployment URL and the claim URL to the user so they can keep the site permanently.
21
+ Without SHIP_TOKEN, deployments are public and expire in ${PUBLIC_EXPIRY}. ${B.claim}
66
22
 
67
23
  With SHIP_TOKEN configured, deployments go to the user's account and never expire. Listing, managing, and domain operations also require SHIP_TOKEN.
68
24
 
69
- Concepts:
70
- - Deployment: an immutable set of files with an instant URL (e.g. happy-cat-abc1234.shipstatic.com). No setup needed.
71
- - Domain: a custom domain (e.g. www.example.com) pointing to a deployment. Optional. Subdomains only — not apex domains.
25
+ ${B.conceptsHeader}
26
+ ${B.deploymentConcept}
27
+ ${B.domainConcept}
72
28
 
73
- To add a custom domain: domains_validate → domains_set → domains_records (show DNS records to user) → user configures DNS → domains_verify.`;
74
- export function createServer(ship) {
29
+ ${B.domainWorkflow}`;
30
+ /**
31
+ * Builds the stdio server's full 15-tool surface over an injected client.
32
+ */
33
+ export function createServer(ship, options) {
34
+ const { version, via = DeploymentVia.MCP } = options;
75
35
  const server = new McpServer({
76
- name: 'shipstatic',
36
+ name: SERVER_NAME,
77
37
  version,
78
38
  }, {
79
39
  instructions: INSTRUCTIONS,
80
40
  });
81
41
  // Deployments
82
- server.registerTool('deployments_upload', {
83
- description: 'Deploy a static site instantly — free, no account or API key required. Returns the live URL, file count, and size. Without SHIP_TOKEN, the response includes a claim URL (site expires in 3 days) — always show both the deployment URL and claim URL to the user. To make the site private, pass `password`; always show the password to the user if you set one.',
42
+ server.registerTool(UPLOAD_TOOL_NAME, {
43
+ description: `Deploy a static site instantly — ${D.free}. Returns the live URL, file count, and size. Without SHIP_TOKEN, the response includes a claim URL (site expires in ${PUBLIC_EXPIRY}) — always show both the deployment URL and claim URL to the user. ${D.password}`,
84
44
  annotations: CREATE,
85
45
  inputSchema: {
86
46
  path: z
87
47
  .string()
88
48
  .describe('Absolute path to the build output directory to deploy (e.g. "/Users/me/project/dist")'),
89
- labels: z
90
- .array(z.string())
91
- .optional()
92
- .describe(`Labels for organizing deployments (e.g. ["production", "v1.2"]). Lowercase, ${LABEL_CONSTRAINTS.MIN_LENGTH}-${LABEL_CONSTRAINTS.MAX_LENGTH} chars, allows . _ - separators. Up to ${LABEL_CONSTRAINTS.MAX_COUNT}.`),
93
- password: z
94
- .string()
95
- .optional()
96
- .describe(`Optional password to gate the deployment behind an unlock prompt (${PASSWORD_CONSTRAINTS.MIN_LENGTH}–${PASSWORD_CONSTRAINTS.MAX_LENGTH} characters; whitespace significant). Visitors must enter this password before viewing the site, including on any custom domains pointing at it.`),
97
- idempotencyKey: z
98
- .string()
99
- .optional()
100
- .describe(`Makes this deploy replayable instead of repeatable. A deploy is not naturally idempotent: if a call times out you cannot tell "it never landed" from "it landed and the response was lost", and retrying creates a second deployment. Send the same key on the retry and the original deployment is replayed instead (within ${IDEMPOTENCY_KEY_CONSTRAINTS.WINDOW_SECONDS / 3600} hours). Key the ATTEMPT — a run id, a commit sha, a uuid minted before the first try — never one minted fresh on each retry, which would defeat the point.`),
101
- },
102
- }, ({ path, labels, password, idempotencyKey }) => call(() => ship.deployments.upload(path, { labels, password, idempotencyKey, via: 'mcp' })));
103
- server.registerTool('deployments_list', {
104
- description: `List all deployments with their URLs, status, labels, and password protection state.${PAGING_NOTE}`,
105
- annotations: READ,
106
- inputSchema: PAGINATION_INPUT,
107
- }, ({ limit, cursor }) => call(() => ship.deployments.list({ limit, cursor })));
108
- server.registerTool('deployments_get', {
109
- description: 'Get deployment details including URL, status, file count, size, labels, and password protection state.',
110
- annotations: READ,
111
- inputSchema: {
112
- deployment: z
113
- .string()
114
- .describe('Deployment hostname (e.g. "happy-cat-abc1234.shipstatic.com"). Returned by deployments_upload or deployments_list.'),
115
- },
116
- }, ({ deployment }) => call(() => ship.deployments.get(deployment)));
117
- server.registerTool('deployments_set', {
118
- description: 'Update deployment labels. Replaces all existing labels.',
119
- annotations: WRITE,
120
- inputSchema: {
121
- deployment: z
122
- .string()
123
- .describe('Deployment hostname (e.g. "happy-cat-abc1234.shipstatic.com"). Use deployments_list to find deployments.'),
124
- labels: z
125
- .array(z.string())
126
- .describe('Labels to set. Replaces all existing labels. Pass empty array to clear.'),
127
- },
128
- }, ({ deployment, labels }) => call(() => ship.deployments.set(deployment, { labels })));
129
- server.registerTool('deployments_delete', {
130
- description: 'Permanently delete a deployment and its files. You MUST confirm with the user before calling this tool, referencing the deployment.',
131
- annotations: DESTRUCTIVE,
132
- inputSchema: {
133
- deployment: z
134
- .string()
135
- .describe('Deployment hostname to delete (e.g. "happy-cat-abc1234.shipstatic.com")'),
136
- },
137
- }, ({ deployment }) => call(() => ship.deployments.delete(deployment)));
138
- // Domains
139
- server.registerTool('domains_set', {
140
- description: 'Create or update a custom domain. Can reserve a name (omit deployment), link it to a deployment, switch deployments, or update labels. After creating, call domains_records and show the DNS records to the user.',
141
- annotations: WRITE,
142
- inputSchema: {
143
- domain: z.string().describe('Domain name (e.g. "www.example.com" or "blog.example.com")'),
144
- deployment: z
145
- .string()
146
- .optional()
147
- .describe('Deployment to serve on this domain (e.g. "happy-cat-abc1234.shipstatic.com"). Omit to reserve the domain without linking.'),
148
- labels: z
149
- .array(z.string())
150
- .optional()
151
- .describe('Labels for organizing domains (e.g. ["production"]).'),
152
- },
153
- }, ({ domain, deployment, labels }) => call(() => ship.domains.set(domain, { deployment, labels })));
154
- server.registerTool('domains_list', {
155
- description: `List all domains with their URLs, linked deployment, and verification status.${PAGING_NOTE}`,
156
- annotations: READ,
157
- inputSchema: PAGINATION_INPUT,
158
- }, ({ limit, cursor }) => call(() => ship.domains.list({ limit, cursor })));
159
- server.registerTool('domains_get', {
160
- description: 'Get domain details including URL, linked deployment, verification status, and labels.',
161
- annotations: READ,
162
- inputSchema: {
163
- domain: z
164
- .string()
165
- .describe('Domain name (e.g. "www.example.com"). Use domains_list to find names.'),
166
- },
167
- }, ({ domain }) => call(() => ship.domains.get(domain)));
168
- server.registerTool('domains_records', {
169
- description: 'Get the DNS records the user needs to configure at their DNS provider. Call after domains_set. You MUST show the returned records to the user.',
170
- annotations: READ,
171
- inputSchema: {
172
- domain: z
173
- .string()
174
- .describe('Domain name. Must be a domain previously created with domains_set.'),
175
- },
176
- }, ({ domain }) => call(() => ship.domains.records(domain)));
177
- server.registerTool('domains_dns', {
178
- description: 'Look up the DNS provider for a domain (e.g. Cloudflare, Namecheap). Helps the user know where to configure their DNS records.',
179
- annotations: READ,
180
- inputSchema: {
181
- domain: z
182
- .string()
183
- .describe('Domain name to look up DNS provider for (e.g. "www.example.com")'),
184
- },
185
- }, ({ domain }) => call(() => ship.domains.dns(domain)));
186
- server.registerTool('domains_share', {
187
- description: 'Get a shareable DNS setup hash for a domain. The hash can be shared with the user so they can view the required DNS records without needing an API key.',
188
- annotations: READ,
189
- inputSchema: {
190
- domain: z
191
- .string()
192
- .describe('Domain name to generate a share link for. Must be a domain previously created with domains_set.'),
193
- },
194
- }, ({ domain }) => call(() => ship.domains.share(domain)));
195
- server.registerTool('domains_validate', {
196
- description: 'Check if a domain name is valid and available before creating it. Returns the normalized form and availability.',
197
- annotations: READ,
198
- inputSchema: {
199
- domain: z
200
- .string()
201
- .describe('Domain name to check (e.g. "www.example.com"). Call before domains_set to check availability.'),
202
- },
203
- }, ({ domain }) => call(() => ship.domains.validate(domain)));
204
- server.registerTool('domains_verify', {
205
- description: 'Trigger DNS verification for a custom domain. Call after the user has configured DNS records from domains_records. Verification is asynchronous — the domain status updates once DNS propagates.',
206
- annotations: WRITE,
207
- inputSchema: {
208
- domain: z
209
- .string()
210
- .describe('Domain name to verify DNS for. Must be a domain previously created with domains_set.'),
211
- },
212
- }, ({ domain }) => call(() => ship.domains.verify(domain)));
213
- server.registerTool('domains_delete', {
214
- description: 'Permanently delete a domain. You MUST confirm with the user before calling this tool, referencing the domain name.',
215
- annotations: DESTRUCTIVE,
216
- inputSchema: {
217
- domain: z.string().describe('Domain name to delete (e.g. "www.example.com")'),
218
- },
219
- }, ({ domain }) => call(() => ship.domains.delete(domain)));
220
- // Debugging
221
- server.registerTool('whoami', {
222
- description: 'Show authenticated account details including email, plan, and usage.',
223
- annotations: READ,
224
- }, () => call(() => ship.whoami()));
49
+ labels: z.array(z.string()).optional().describe(PARAM_DESCRIPTIONS.labels),
50
+ password: z.string().optional().describe(PARAM_DESCRIPTIONS.password),
51
+ idempotencyKey: z.string().optional().describe(PARAM_DESCRIPTIONS.idempotencyKey),
52
+ },
53
+ }, ({ path, labels, password, idempotencyKey }) => call(() => ship.deployments.upload(path, { labels, password, idempotencyKey, via })));
54
+ // The other fourteen. Identical on every transport, so they live in the
55
+ // shared package rather than here — see tools.ts for why upload is not
56
+ // among them.
57
+ registerAccountTools(server, ship, call);
225
58
  return server;
226
59
  }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The account-tied toolset — fourteen tools, identical on every transport.
3
+ *
4
+ * `deployments_upload` is not here, and the split is exactly the product's
5
+ * own shape rather than a convenience:
6
+ *
7
+ * - **Upload is the anonymous door.** It is the one operation that works
8
+ * with no account, and it is the one whose INPUT differs by transport —
9
+ * a filesystem path over stdio, inline bytes over HTTP, because a Worker
10
+ * has no filesystem. It also carries the Apps-SDK widget hosted-side.
11
+ * So it is authored per transport, in each `server.ts`.
12
+ * - **Everything else needs an identity**, and once a transport has one,
13
+ * nothing about these fourteen depends on how the bytes arrived. Same
14
+ * names, same schemas, same prose, same 1:1 SDK calls.
15
+ *
16
+ * That is why they live in the shared package: when the hosted transport
17
+ * gains OAuth it registers this function and has the complete toolset, rather
18
+ * than someone copying fourteen definitions into a second repo — which is the
19
+ * moment the two surfaces would begin to drift. The cost of doing it after
20
+ * the copy is a de-duplication under deadline; the cost of doing it before is
21
+ * this file.
22
+ *
23
+ * **The catalogue is static; identity decides what SUCCEEDS.** These are
24
+ * registered whether or not a credential is present — an anonymous caller
25
+ * sees them and gets a typed authentication error naming how to authenticate
26
+ * on *this* transport (the hint is `createCall`'s one per-transport argument).
27
+ * A tool list that changes shape under the caller would be a second, dynamic
28
+ * contract for an agent to track, and MCP clients cache the catalogue.
29
+ */
30
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
31
+ import type Ship from '@shipstatic/ship';
32
+ import type { CallFn } from './call.js';
33
+ /**
34
+ * The fourteen, by name, in registration order.
35
+ *
36
+ * Exported so a second transport can state its expected catalogue as
37
+ * `[UPLOAD_TOOL_NAME, ...ACCOUNT_TOOL_NAMES]` instead of listing fifteen
38
+ * strings it would then have to keep in agreement with this file — the hosted
39
+ * parity fence is the consumer, and "fifteen" is otherwise a number two repos
40
+ * count separately.
41
+ *
42
+ * **Deliberately a list beside the registrations rather than a table they are
43
+ * generated from.** A `Record<name, factory>` would make the pairing
44
+ * structural, and it would cost the zod→handler inference every one of the
45
+ * fourteen one-liners below relies on: `({ deployment }) => …` is typed today
46
+ * from the `inputSchema` literal in the same call, and a loop over a
47
+ * heterogeneous table cannot correlate the two. The same guarantee costs
48
+ * nothing as a set comparison, and `tests/server.test.ts` makes it — through a
49
+ * real `tools/list`, so a registration without a name, a name without a
50
+ * registration, and a typo in either all turn it red.
51
+ */
52
+ export declare const ACCOUNT_TOOL_NAMES: readonly ["deployments_list", "deployments_get", "deployments_set", "deployments_delete", "domains_set", "domains_list", "domains_get", "domains_records", "domains_dns", "domains_share", "domains_validate", "domains_verify", "domains_delete", "whoami"];
53
+ export declare function registerAccountTools(server: McpServer, ship: Ship, call: CallFn): void;
package/dist/tools.js ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * The account-tied toolset — fourteen tools, identical on every transport.
3
+ *
4
+ * `deployments_upload` is not here, and the split is exactly the product's
5
+ * own shape rather than a convenience:
6
+ *
7
+ * - **Upload is the anonymous door.** It is the one operation that works
8
+ * with no account, and it is the one whose INPUT differs by transport —
9
+ * a filesystem path over stdio, inline bytes over HTTP, because a Worker
10
+ * has no filesystem. It also carries the Apps-SDK widget hosted-side.
11
+ * So it is authored per transport, in each `server.ts`.
12
+ * - **Everything else needs an identity**, and once a transport has one,
13
+ * nothing about these fourteen depends on how the bytes arrived. Same
14
+ * names, same schemas, same prose, same 1:1 SDK calls.
15
+ *
16
+ * That is why they live in the shared package: when the hosted transport
17
+ * gains OAuth it registers this function and has the complete toolset, rather
18
+ * than someone copying fourteen definitions into a second repo — which is the
19
+ * moment the two surfaces would begin to drift. The cost of doing it after
20
+ * the copy is a de-duplication under deadline; the cost of doing it before is
21
+ * this file.
22
+ *
23
+ * **The catalogue is static; identity decides what SUCCEEDS.** These are
24
+ * registered whether or not a credential is present — an anonymous caller
25
+ * sees them and gets a typed authentication error naming how to authenticate
26
+ * on *this* transport (the hint is `createCall`'s one per-transport argument).
27
+ * A tool list that changes shape under the caller would be a second, dynamic
28
+ * contract for an agent to track, and MCP clients cache the catalogue.
29
+ */
30
+ import { z } from 'zod';
31
+ import { ANNOTATIONS } from './vocabulary.js';
32
+ const { READ, WRITE, DESTRUCTIVE } = ANNOTATIONS;
33
+ /**
34
+ * The fourteen, by name, in registration order.
35
+ *
36
+ * Exported so a second transport can state its expected catalogue as
37
+ * `[UPLOAD_TOOL_NAME, ...ACCOUNT_TOOL_NAMES]` instead of listing fifteen
38
+ * strings it would then have to keep in agreement with this file — the hosted
39
+ * parity fence is the consumer, and "fifteen" is otherwise a number two repos
40
+ * count separately.
41
+ *
42
+ * **Deliberately a list beside the registrations rather than a table they are
43
+ * generated from.** A `Record<name, factory>` would make the pairing
44
+ * structural, and it would cost the zod→handler inference every one of the
45
+ * fourteen one-liners below relies on: `({ deployment }) => …` is typed today
46
+ * from the `inputSchema` literal in the same call, and a loop over a
47
+ * heterogeneous table cannot correlate the two. The same guarantee costs
48
+ * nothing as a set comparison, and `tests/server.test.ts` makes it — through a
49
+ * real `tools/list`, so a registration without a name, a name without a
50
+ * registration, and a typo in either all turn it red.
51
+ */
52
+ export const ACCOUNT_TOOL_NAMES = [
53
+ 'deployments_list',
54
+ 'deployments_get',
55
+ 'deployments_set',
56
+ 'deployments_delete',
57
+ 'domains_set',
58
+ 'domains_list',
59
+ 'domains_get',
60
+ 'domains_records',
61
+ 'domains_dns',
62
+ 'domains_share',
63
+ 'domains_validate',
64
+ 'domains_verify',
65
+ 'domains_delete',
66
+ 'whoami',
67
+ ];
68
+ /**
69
+ * The pagination surface, shared by every list tool because it is one
70
+ * contract, not two. A list answers `{<collection>, cursor}` and nothing
71
+ * else — `cursor` carries the whole has-more signal and is null on the last
72
+ * page, so there is no `total` to ask for and no has-more boolean.
73
+ *
74
+ * No upper bound is stated here on purpose. The API clamps an unusable
75
+ * `limit` server-side and owns that number; restating a cap in the tool
76
+ * schema would give one fact two owners and let them drift. `min(1)` is not
77
+ * a cap — it rejects a value that could never mean anything.
78
+ */
79
+ const PAGINATION_INPUT = {
80
+ limit: z
81
+ .number()
82
+ .int()
83
+ .min(1)
84
+ .optional()
85
+ .describe('Maximum number of items to return in one page. Omit for the server default.'),
86
+ cursor: z
87
+ .string()
88
+ .optional()
89
+ .describe("Opaque position from the previous response's `cursor` field; omit for the first page."),
90
+ };
91
+ /** Appended to every list tool's description — the paging contract, stated once. */
92
+ const PAGING_NOTE = " The response's `cursor` is null on the last page; pass it back as `cursor` to fetch the next.";
93
+ /** The deployment argument, described identically wherever it is accepted. */
94
+ const DEPLOYMENT_EXAMPLE = 'happy-cat-abc1234.shipstatic.com';
95
+ export function registerAccountTools(server, ship, call) {
96
+ // Deployments
97
+ server.registerTool('deployments_list', {
98
+ description: `List all deployments with their URLs, status, labels, and password protection state.${PAGING_NOTE}`,
99
+ annotations: READ,
100
+ inputSchema: PAGINATION_INPUT,
101
+ }, ({ limit, cursor }) => call(() => ship.deployments.list({ limit, cursor })));
102
+ server.registerTool('deployments_get', {
103
+ description: 'Get deployment details including URL, status, file count, size, labels, and password protection state.',
104
+ annotations: READ,
105
+ inputSchema: {
106
+ deployment: z
107
+ .string()
108
+ .describe(`Deployment hostname (e.g. "${DEPLOYMENT_EXAMPLE}"). Returned by deployments_upload or deployments_list.`),
109
+ },
110
+ }, ({ deployment }) => call(() => ship.deployments.get(deployment)));
111
+ server.registerTool('deployments_set', {
112
+ description: 'Update deployment labels. Replaces all existing labels.',
113
+ annotations: WRITE,
114
+ inputSchema: {
115
+ deployment: z
116
+ .string()
117
+ .describe(`Deployment hostname (e.g. "${DEPLOYMENT_EXAMPLE}"). Use deployments_list to find deployments.`),
118
+ labels: z
119
+ .array(z.string())
120
+ .describe('Labels to set. Replaces all existing labels. Pass empty array to clear.'),
121
+ },
122
+ }, ({ deployment, labels }) => call(() => ship.deployments.set(deployment, { labels })));
123
+ server.registerTool('deployments_delete', {
124
+ description: 'Permanently delete a deployment and its files. You MUST confirm with the user before calling this tool, referencing the deployment.',
125
+ annotations: DESTRUCTIVE,
126
+ inputSchema: {
127
+ deployment: z
128
+ .string()
129
+ .describe(`Deployment hostname to delete (e.g. "${DEPLOYMENT_EXAMPLE}")`),
130
+ },
131
+ }, ({ deployment }) => call(() => ship.deployments.delete(deployment)));
132
+ // Domains
133
+ server.registerTool('domains_set', {
134
+ description: 'Create or update a custom domain. Can reserve a name (omit deployment), link it to a deployment, switch deployments, or update labels. After creating, call domains_records and show the DNS records to the user.',
135
+ annotations: WRITE,
136
+ inputSchema: {
137
+ domain: z.string().describe('Domain name (e.g. "www.example.com" or "blog.example.com")'),
138
+ deployment: z
139
+ .string()
140
+ .optional()
141
+ .describe(`Deployment to serve on this domain (e.g. "${DEPLOYMENT_EXAMPLE}"). Omit to reserve the domain without linking.`),
142
+ labels: z
143
+ .array(z.string())
144
+ .optional()
145
+ .describe('Labels for organizing domains (e.g. ["production"]).'),
146
+ },
147
+ }, ({ domain, deployment, labels }) => call(() => ship.domains.set(domain, { deployment, labels })));
148
+ server.registerTool('domains_list', {
149
+ description: `List all domains with their URLs, linked deployment, and verification status.${PAGING_NOTE}`,
150
+ annotations: READ,
151
+ inputSchema: PAGINATION_INPUT,
152
+ }, ({ limit, cursor }) => call(() => ship.domains.list({ limit, cursor })));
153
+ server.registerTool('domains_get', {
154
+ description: 'Get domain details including URL, linked deployment, verification status, and labels.',
155
+ annotations: READ,
156
+ inputSchema: {
157
+ domain: z
158
+ .string()
159
+ .describe('Domain name (e.g. "www.example.com"). Use domains_list to find names.'),
160
+ },
161
+ }, ({ domain }) => call(() => ship.domains.get(domain)));
162
+ server.registerTool('domains_records', {
163
+ description: 'Get the DNS records the user needs to configure at their DNS provider. Call after domains_set. You MUST show the returned records to the user.',
164
+ annotations: READ,
165
+ inputSchema: {
166
+ domain: z
167
+ .string()
168
+ .describe('Domain name. Must be a domain previously created with domains_set.'),
169
+ },
170
+ }, ({ domain }) => call(() => ship.domains.records(domain)));
171
+ server.registerTool('domains_dns', {
172
+ description: 'Look up the DNS provider for a domain (e.g. Cloudflare, Namecheap). Helps the user know where to configure their DNS records.',
173
+ annotations: READ,
174
+ inputSchema: {
175
+ domain: z
176
+ .string()
177
+ .describe('Domain name to look up DNS provider for (e.g. "www.example.com")'),
178
+ },
179
+ }, ({ domain }) => call(() => ship.domains.dns(domain)));
180
+ server.registerTool('domains_share', {
181
+ description: 'Get a shareable DNS setup hash for a domain. The hash can be shared with the user so they can view the required DNS records without needing an API key.',
182
+ annotations: READ,
183
+ inputSchema: {
184
+ domain: z
185
+ .string()
186
+ .describe('Domain name to generate a share link for. Must be a domain previously created with domains_set.'),
187
+ },
188
+ }, ({ domain }) => call(() => ship.domains.share(domain)));
189
+ server.registerTool('domains_validate', {
190
+ description: 'Check if a domain name is valid and available before creating it. Returns the normalized form and availability.',
191
+ annotations: READ,
192
+ inputSchema: {
193
+ domain: z
194
+ .string()
195
+ .describe('Domain name to check (e.g. "www.example.com"). Call before domains_set to check availability.'),
196
+ },
197
+ }, ({ domain }) => call(() => ship.domains.validate(domain)));
198
+ server.registerTool('domains_verify', {
199
+ description: 'Trigger DNS verification for a custom domain. Call after the user has configured DNS records from domains_records. Verification is asynchronous — the domain status updates once DNS propagates.',
200
+ annotations: WRITE,
201
+ inputSchema: {
202
+ domain: z
203
+ .string()
204
+ .describe('Domain name to verify DNS for. Must be a domain previously created with domains_set.'),
205
+ },
206
+ }, ({ domain }) => call(() => ship.domains.verify(domain)));
207
+ server.registerTool('domains_delete', {
208
+ description: 'Permanently delete a domain. You MUST confirm with the user before calling this tool, referencing the domain name.',
209
+ annotations: DESTRUCTIVE,
210
+ inputSchema: {
211
+ domain: z.string().describe('Domain name to delete (e.g. "www.example.com")'),
212
+ },
213
+ }, ({ domain }) => call(() => ship.domains.delete(domain)));
214
+ // Account
215
+ server.registerTool('whoami', {
216
+ description: 'Show authenticated account details including email, plan, and usage.',
217
+ annotations: READ,
218
+ }, () => call(() => ship.whoami()));
219
+ }