@shipstatic/mcp 1.0.0-beta.1 → 1.0.0-beta.3
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/bin.d.ts +2 -0
- package/dist/bin.js +40 -0
- package/dist/call.d.ts +46 -1
- package/dist/call.js +44 -14
- package/dist/index.d.ts +21 -2
- package/dist/index.js +21 -22
- package/dist/server.d.ts +9 -1
- package/dist/server.js +22 -188
- package/dist/tools.d.ts +33 -0
- package/dist/tools.js +184 -0
- package/dist/vocabulary.d.ts +79 -0
- package/dist/vocabulary.js +62 -0
- package/package.json +9 -2
package/dist/bin.d.ts
ADDED
package/dist/bin.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* THE EXECUTABLE — `dist/bin.js`, the file `npx @shipstatic/mcp` runs. It is
|
|
4
|
+
* the only module here with side effects on import.
|
|
5
|
+
*
|
|
6
|
+
* `index.ts` beside it is a library and stays inert, which is what lets the
|
|
7
|
+
* hosted transport import this package's vocabulary instead of re-authoring
|
|
8
|
+
* it. The two used to be one file: `main` and `bin` in `package.json` both
|
|
9
|
+
* pointed at `index.ts`, so importing the package started a stdio server and,
|
|
10
|
+
* on failure, called `process.exit` in its consumer. Nothing could be shared
|
|
11
|
+
* because there was nothing importable to share. `npm/ship` split the same
|
|
12
|
+
* knot the same way (`bin.ts` executable, `index.ts` library) — a module
|
|
13
|
+
* boundary says the same thing to every caller.
|
|
14
|
+
*/
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
17
|
+
import Ship from '@shipstatic/ship';
|
|
18
|
+
import { createServer } from './server.js';
|
|
19
|
+
// The executable knows its own manifest; the library it drives does not have
|
|
20
|
+
// to. `createServer` takes the version as an argument precisely so no module
|
|
21
|
+
// below this one needs `node:module` in its import graph.
|
|
22
|
+
const { version } = createRequire(import.meta.url)('../package.json');
|
|
23
|
+
async function main() {
|
|
24
|
+
// SHIP_TOKEN is optional — without it, deployments are public (3-day expiry).
|
|
25
|
+
// The SDK coerces empty strings to undefined, so we can pass through directly.
|
|
26
|
+
//
|
|
27
|
+
// One credential slot, any platform token: the value's prefix says what it
|
|
28
|
+
// is (`ship-` API key, `deploy-` deploy token, anything else an opaque
|
|
29
|
+
// bearer) and the server classifies it. MCP never has to know which kind it
|
|
30
|
+
// holds.
|
|
31
|
+
const ship = new Ship({ token: process.env.SHIP_TOKEN });
|
|
32
|
+
const server = createServer(ship, version);
|
|
33
|
+
const transport = new StdioServerTransport();
|
|
34
|
+
await server.connect(transport);
|
|
35
|
+
console.error('ShipStatic MCP Server running on stdio');
|
|
36
|
+
}
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
console.error('Fatal error:', error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
package/dist/call.d.ts
CHANGED
|
@@ -1,2 +1,47 @@
|
|
|
1
1
|
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
2
|
-
|
|
2
|
+
/**
|
|
3
|
+
* The two error arms that earn a hint. Everything else relays verbatim — a
|
|
4
|
+
* hint on any other type sends the agent chasing a credential that is not the
|
|
5
|
+
* problem.
|
|
6
|
+
*
|
|
7
|
+
* They are ARGUMENTS rather than constants because they are the one part of
|
|
8
|
+
* the mapping that legitimately differs per transport: stdio can name the
|
|
9
|
+
* environment variable it owns, and the hosted endpoint deliberately cannot
|
|
10
|
+
* (it has no configuration of its own, and naming another package's variable
|
|
11
|
+
* is how this pair silently desynchronised once already).
|
|
12
|
+
*/
|
|
13
|
+
export interface ErrorHints {
|
|
14
|
+
/** Appended after `Hint: ` when the SDK rejects the credential. */
|
|
15
|
+
authentication: string;
|
|
16
|
+
/** Appended after `Hint: ` when the platform refuses an authenticated call. */
|
|
17
|
+
forbidden: string;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* The wrapper every tool handler delegates to. Named because the shared
|
|
21
|
+
* toolset takes it as an argument: the tools are identical across transports,
|
|
22
|
+
* the hints inside `call` are not.
|
|
23
|
+
*/
|
|
24
|
+
export type CallFn = <T>(fn: () => Promise<T>) => Promise<CallToolResult>;
|
|
25
|
+
export interface CallOptions {
|
|
26
|
+
hints: ErrorHints;
|
|
27
|
+
/**
|
|
28
|
+
* Attach a plain-object result as `structuredContent` beside the text.
|
|
29
|
+
* Hosted-only today: it is what feeds the Apps-SDK widget, and the MCP spec
|
|
30
|
+
* pairs it with an `outputSchema`, which is a hand-maintained zod twin of a
|
|
31
|
+
* published type. One such twin is worth it for a widget; fifteen would be a
|
|
32
|
+
* drift surface with no consumer asking for it. See
|
|
33
|
+
* `cloudflare/mcp/CLAUDE.md`, "What deliberately differs".
|
|
34
|
+
*/
|
|
35
|
+
structuredContent?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Builds the `call()` wrapper both transports use: SDK promise in, MCP
|
|
39
|
+
* `CallToolResult` out.
|
|
40
|
+
*
|
|
41
|
+
* The success envelope, the `'Done.'` sentinel for a void result, the order
|
|
42
|
+
* the error arms are tested in, and the `Details:` appendix are all wire
|
|
43
|
+
* facts an agent observes — so they live here once, rather than in two files
|
|
44
|
+
* kept equal by review.
|
|
45
|
+
*/
|
|
46
|
+
export declare function createCall(options: CallOptions): CallFn;
|
|
47
|
+
export declare const call: CallFn;
|
package/dist/call.js
CHANGED
|
@@ -1,24 +1,48 @@
|
|
|
1
1
|
import { ErrorType, isShipError } from '@shipstatic/ship';
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
2
|
+
/**
|
|
3
|
+
* Builds the `call()` wrapper both transports use: SDK promise in, MCP
|
|
4
|
+
* `CallToolResult` out.
|
|
5
|
+
*
|
|
6
|
+
* The success envelope, the `'Done.'` sentinel for a void result, the order
|
|
7
|
+
* the error arms are tested in, and the `Details:` appendix are all wire
|
|
8
|
+
* facts an agent observes — so they live here once, rather than in two files
|
|
9
|
+
* kept equal by review.
|
|
10
|
+
*/
|
|
11
|
+
export function createCall(options) {
|
|
12
|
+
const { hints, structuredContent = false } = options;
|
|
13
|
+
return async function call(fn) {
|
|
14
|
+
try {
|
|
15
|
+
const result = await fn();
|
|
16
|
+
// A void SDK method has nothing to serialize; every other result is the
|
|
17
|
+
// wire shape verbatim, because an agent reads exactly what the API sent.
|
|
18
|
+
if (result === undefined) {
|
|
19
|
+
return { content: [{ type: 'text', text: 'Done.' }] };
|
|
20
|
+
}
|
|
21
|
+
const text = JSON.stringify(result, null, 2);
|
|
22
|
+
const structured = structuredContent && isPlainObject(result)
|
|
23
|
+
? result
|
|
24
|
+
: undefined;
|
|
25
|
+
return {
|
|
26
|
+
content: [{ type: 'text', text }],
|
|
27
|
+
...(structured ? { structuredContent: structured } : {}),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
return toErrorResult(error, hints);
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
function isPlainObject(value) {
|
|
36
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
11
37
|
}
|
|
12
|
-
function
|
|
38
|
+
function toErrorResult(error, hints) {
|
|
13
39
|
if (isShipError(error)) {
|
|
14
40
|
let message = error.message;
|
|
15
41
|
if (error.isType(ErrorType.Authentication)) {
|
|
16
|
-
message +=
|
|
17
|
-
'\n\nHint: Set a free SHIP_TOKEN environment variable in your MCP server configuration.';
|
|
42
|
+
message += `\n\nHint: ${hints.authentication}`;
|
|
18
43
|
}
|
|
19
44
|
if (error.isType(ErrorType.Forbidden)) {
|
|
20
|
-
message +=
|
|
21
|
-
'\n\nHint: This action is not permitted. Likely cause: plan limits reached or the account is terminated. Stop retrying — the user needs to upgrade or contact support at https://my.shipstatic.com.';
|
|
45
|
+
message += `\n\nHint: ${hints.forbidden}`;
|
|
22
46
|
}
|
|
23
47
|
if (error.isType(ErrorType.Validation) && error.details) {
|
|
24
48
|
message += `\n\nDetails: ${safeStringify(error.details)}`;
|
|
@@ -42,3 +66,9 @@ function safeStringify(value) {
|
|
|
42
66
|
return String(value);
|
|
43
67
|
}
|
|
44
68
|
}
|
|
69
|
+
/** stdio's hints: this package owns `SHIP_TOKEN` and is the only side that may name it. */
|
|
70
|
+
const STDIO_HINTS = {
|
|
71
|
+
authentication: 'Set a free SHIP_TOKEN environment variable in your MCP server configuration.',
|
|
72
|
+
forbidden: 'This action is not permitted. Likely cause: plan limits reached or the account is terminated. Stop retrying — the user needs to upgrade or contact support at https://my.shipstatic.com.',
|
|
73
|
+
};
|
|
74
|
+
export const call = createCall({ hints: STDIO_HINTS });
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* The library entry — importing this file has NO side effects.
|
|
3
|
+
*
|
|
4
|
+
* That is the whole point of it: `bin.ts` is the executable, and this is what
|
|
5
|
+
* a consumer imports. Today there is exactly one such consumer, the hosted
|
|
6
|
+
* Streamable-HTTP transport in the platform's private `cloudflare/mcp`, which
|
|
7
|
+
* takes the vocabulary below rather than re-authoring the strings an agent
|
|
8
|
+
* reads. "One product, two transports" is only true if one of them can import
|
|
9
|
+
* the other.
|
|
10
|
+
*
|
|
11
|
+
* **The surface is curated, not swept.** Every export here is a deliberate
|
|
12
|
+
* public commitment under semver; the modules behind it hold plenty that is
|
|
13
|
+
* not (`toErrorResult`, `safeStringify`, the INSTRUCTIONS template, every tool
|
|
14
|
+
* registration). `export *` would publish implementation detail and make the
|
|
15
|
+
* next refactor a breaking change. `tests/index.test.ts` fences both
|
|
16
|
+
* directions — nothing missing, nothing extra.
|
|
17
|
+
*/
|
|
18
|
+
export { type CallFn, type CallOptions, call, createCall, type ErrorHints } from './call.js';
|
|
19
|
+
export { createServer } from './server.js';
|
|
20
|
+
export { registerAccountTools } from './tools.js';
|
|
21
|
+
export { ANNOTATIONS, PARAM_DESCRIPTIONS } from './vocabulary.js';
|
package/dist/index.js
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
});
|
|
1
|
+
/**
|
|
2
|
+
* The library entry — importing this file has NO side effects.
|
|
3
|
+
*
|
|
4
|
+
* That is the whole point of it: `bin.ts` is the executable, and this is what
|
|
5
|
+
* a consumer imports. Today there is exactly one such consumer, the hosted
|
|
6
|
+
* Streamable-HTTP transport in the platform's private `cloudflare/mcp`, which
|
|
7
|
+
* takes the vocabulary below rather than re-authoring the strings an agent
|
|
8
|
+
* reads. "One product, two transports" is only true if one of them can import
|
|
9
|
+
* the other.
|
|
10
|
+
*
|
|
11
|
+
* **The surface is curated, not swept.** Every export here is a deliberate
|
|
12
|
+
* public commitment under semver; the modules behind it hold plenty that is
|
|
13
|
+
* not (`toErrorResult`, `safeStringify`, the INSTRUCTIONS template, every tool
|
|
14
|
+
* registration). `export *` would publish implementation detail and make the
|
|
15
|
+
* next refactor a breaking change. `tests/index.test.ts` fences both
|
|
16
|
+
* directions — nothing missing, nothing extra.
|
|
17
|
+
*/
|
|
18
|
+
export { call, createCall } from './call.js';
|
|
19
|
+
export { createServer } from './server.js';
|
|
20
|
+
export { registerAccountTools } from './tools.js';
|
|
21
|
+
export { ANNOTATIONS, PARAM_DESCRIPTIONS } from './vocabulary.js';
|
package/dist/server.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import type Ship from '@shipstatic/ship';
|
|
3
|
-
|
|
3
|
+
/**
|
|
4
|
+
* Builds the stdio server's full 15-tool surface over an injected client.
|
|
5
|
+
*
|
|
6
|
+
* `version` is a parameter rather than something this module reads for itself:
|
|
7
|
+
* the executable knows its own package manifest, a library must not assume it
|
|
8
|
+
* has one, and reaching for `node:module` here would put a Node builtin in the
|
|
9
|
+
* import graph of a module the Workers-hosted transport also loads.
|
|
10
|
+
*/
|
|
11
|
+
export declare function createServer(ship: Ship, version: string): McpServer;
|
package/dist/server.js
CHANGED
|
@@ -1,63 +1,13 @@
|
|
|
1
|
-
import { createRequire } from 'node:module';
|
|
2
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
|
-
import { IDEMPOTENCY_KEY_CONSTRAINTS
|
|
2
|
+
import { IDEMPOTENCY_KEY_CONSTRAINTS } from '@shipstatic/ship';
|
|
4
3
|
import { z } from 'zod';
|
|
5
4
|
import { call } from './call.js';
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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.";
|
|
5
|
+
import { registerAccountTools } from './tools.js';
|
|
6
|
+
import { ANNOTATIONS, PARAM_DESCRIPTIONS } 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;
|
|
61
11
|
const INSTRUCTIONS = `ShipStatic deploys static websites instantly. Free, no account required.
|
|
62
12
|
|
|
63
13
|
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.
|
|
@@ -71,7 +21,15 @@ Concepts:
|
|
|
71
21
|
- Domain: a custom domain (e.g. www.example.com) pointing to a deployment. Optional. Subdomains only — not apex domains.
|
|
72
22
|
|
|
73
23
|
To add a custom domain: domains_validate → domains_set → domains_records (show DNS records to user) → user configures DNS → domains_verify.`;
|
|
74
|
-
|
|
24
|
+
/**
|
|
25
|
+
* Builds the stdio server's full 15-tool surface over an injected client.
|
|
26
|
+
*
|
|
27
|
+
* `version` is a parameter rather than something this module reads for itself:
|
|
28
|
+
* the executable knows its own package manifest, a library must not assume it
|
|
29
|
+
* has one, and reaching for `node:module` here would put a Node builtin in the
|
|
30
|
+
* import graph of a module the Workers-hosted transport also loads.
|
|
31
|
+
*/
|
|
32
|
+
export function createServer(ship, version) {
|
|
75
33
|
const server = new McpServer({
|
|
76
34
|
name: 'shipstatic',
|
|
77
35
|
version,
|
|
@@ -86,141 +44,17 @@ export function createServer(ship) {
|
|
|
86
44
|
path: z
|
|
87
45
|
.string()
|
|
88
46
|
.describe('Absolute path to the build output directory to deploy (e.g. "/Users/me/project/dist")'),
|
|
89
|
-
labels: z
|
|
90
|
-
|
|
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.`),
|
|
47
|
+
labels: z.array(z.string()).optional().describe(PARAM_DESCRIPTIONS.labels),
|
|
48
|
+
password: z.string().optional().describe(PARAM_DESCRIPTIONS.password),
|
|
97
49
|
idempotencyKey: z
|
|
98
50
|
.string()
|
|
99
51
|
.optional()
|
|
100
52
|
.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
53
|
},
|
|
102
54
|
}, ({ path, labels, password, idempotencyKey }) => call(() => ship.deployments.upload(path, { labels, password, idempotencyKey, via: 'mcp' })));
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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()));
|
|
55
|
+
// The other fourteen. Identical on every transport, so they live in the
|
|
56
|
+
// shared package rather than here — see tools.ts for why upload is not
|
|
57
|
+
// among them.
|
|
58
|
+
registerAccountTools(server, ship, call);
|
|
225
59
|
return server;
|
|
226
60
|
}
|
package/dist/tools.d.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
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
|
+
export declare function registerAccountTools(server: McpServer, ship: Ship, call: CallFn): void;
|
package/dist/tools.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
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 pagination surface, shared by every list tool because it is one
|
|
35
|
+
* contract, not two. A list answers `{<collection>, cursor}` and nothing
|
|
36
|
+
* else — `cursor` carries the whole has-more signal and is null on the last
|
|
37
|
+
* page, so there is no `total` to ask for and no has-more boolean.
|
|
38
|
+
*
|
|
39
|
+
* No upper bound is stated here on purpose. The API clamps an unusable
|
|
40
|
+
* `limit` server-side and owns that number; restating a cap in the tool
|
|
41
|
+
* schema would give one fact two owners and let them drift. `min(1)` is not
|
|
42
|
+
* a cap — it rejects a value that could never mean anything.
|
|
43
|
+
*/
|
|
44
|
+
const PAGINATION_INPUT = {
|
|
45
|
+
limit: z
|
|
46
|
+
.number()
|
|
47
|
+
.int()
|
|
48
|
+
.min(1)
|
|
49
|
+
.optional()
|
|
50
|
+
.describe('Maximum number of items to return in one page. Omit for the server default.'),
|
|
51
|
+
cursor: z
|
|
52
|
+
.string()
|
|
53
|
+
.optional()
|
|
54
|
+
.describe("Opaque position from the previous response's `cursor` field; omit for the first page."),
|
|
55
|
+
};
|
|
56
|
+
/** Appended to every list tool's description — the paging contract, stated once. */
|
|
57
|
+
const PAGING_NOTE = " The response's `cursor` is null on the last page; pass it back as `cursor` to fetch the next.";
|
|
58
|
+
/** The deployment argument, described identically wherever it is accepted. */
|
|
59
|
+
const DEPLOYMENT_EXAMPLE = 'happy-cat-abc1234.shipstatic.com';
|
|
60
|
+
export function registerAccountTools(server, ship, call) {
|
|
61
|
+
// Deployments
|
|
62
|
+
server.registerTool('deployments_list', {
|
|
63
|
+
description: `List all deployments with their URLs, status, labels, and password protection state.${PAGING_NOTE}`,
|
|
64
|
+
annotations: READ,
|
|
65
|
+
inputSchema: PAGINATION_INPUT,
|
|
66
|
+
}, ({ limit, cursor }) => call(() => ship.deployments.list({ limit, cursor })));
|
|
67
|
+
server.registerTool('deployments_get', {
|
|
68
|
+
description: 'Get deployment details including URL, status, file count, size, labels, and password protection state.',
|
|
69
|
+
annotations: READ,
|
|
70
|
+
inputSchema: {
|
|
71
|
+
deployment: z
|
|
72
|
+
.string()
|
|
73
|
+
.describe(`Deployment hostname (e.g. "${DEPLOYMENT_EXAMPLE}"). Returned by deployments_upload or deployments_list.`),
|
|
74
|
+
},
|
|
75
|
+
}, ({ deployment }) => call(() => ship.deployments.get(deployment)));
|
|
76
|
+
server.registerTool('deployments_set', {
|
|
77
|
+
description: 'Update deployment labels. Replaces all existing labels.',
|
|
78
|
+
annotations: WRITE,
|
|
79
|
+
inputSchema: {
|
|
80
|
+
deployment: z
|
|
81
|
+
.string()
|
|
82
|
+
.describe(`Deployment hostname (e.g. "${DEPLOYMENT_EXAMPLE}"). Use deployments_list to find deployments.`),
|
|
83
|
+
labels: z
|
|
84
|
+
.array(z.string())
|
|
85
|
+
.describe('Labels to set. Replaces all existing labels. Pass empty array to clear.'),
|
|
86
|
+
},
|
|
87
|
+
}, ({ deployment, labels }) => call(() => ship.deployments.set(deployment, { labels })));
|
|
88
|
+
server.registerTool('deployments_delete', {
|
|
89
|
+
description: 'Permanently delete a deployment and its files. You MUST confirm with the user before calling this tool, referencing the deployment.',
|
|
90
|
+
annotations: DESTRUCTIVE,
|
|
91
|
+
inputSchema: {
|
|
92
|
+
deployment: z
|
|
93
|
+
.string()
|
|
94
|
+
.describe(`Deployment hostname to delete (e.g. "${DEPLOYMENT_EXAMPLE}")`),
|
|
95
|
+
},
|
|
96
|
+
}, ({ deployment }) => call(() => ship.deployments.delete(deployment)));
|
|
97
|
+
// Domains
|
|
98
|
+
server.registerTool('domains_set', {
|
|
99
|
+
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.',
|
|
100
|
+
annotations: WRITE,
|
|
101
|
+
inputSchema: {
|
|
102
|
+
domain: z.string().describe('Domain name (e.g. "www.example.com" or "blog.example.com")'),
|
|
103
|
+
deployment: z
|
|
104
|
+
.string()
|
|
105
|
+
.optional()
|
|
106
|
+
.describe(`Deployment to serve on this domain (e.g. "${DEPLOYMENT_EXAMPLE}"). Omit to reserve the domain without linking.`),
|
|
107
|
+
labels: z
|
|
108
|
+
.array(z.string())
|
|
109
|
+
.optional()
|
|
110
|
+
.describe('Labels for organizing domains (e.g. ["production"]).'),
|
|
111
|
+
},
|
|
112
|
+
}, ({ domain, deployment, labels }) => call(() => ship.domains.set(domain, { deployment, labels })));
|
|
113
|
+
server.registerTool('domains_list', {
|
|
114
|
+
description: `List all domains with their URLs, linked deployment, and verification status.${PAGING_NOTE}`,
|
|
115
|
+
annotations: READ,
|
|
116
|
+
inputSchema: PAGINATION_INPUT,
|
|
117
|
+
}, ({ limit, cursor }) => call(() => ship.domains.list({ limit, cursor })));
|
|
118
|
+
server.registerTool('domains_get', {
|
|
119
|
+
description: 'Get domain details including URL, linked deployment, verification status, and labels.',
|
|
120
|
+
annotations: READ,
|
|
121
|
+
inputSchema: {
|
|
122
|
+
domain: z
|
|
123
|
+
.string()
|
|
124
|
+
.describe('Domain name (e.g. "www.example.com"). Use domains_list to find names.'),
|
|
125
|
+
},
|
|
126
|
+
}, ({ domain }) => call(() => ship.domains.get(domain)));
|
|
127
|
+
server.registerTool('domains_records', {
|
|
128
|
+
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.',
|
|
129
|
+
annotations: READ,
|
|
130
|
+
inputSchema: {
|
|
131
|
+
domain: z
|
|
132
|
+
.string()
|
|
133
|
+
.describe('Domain name. Must be a domain previously created with domains_set.'),
|
|
134
|
+
},
|
|
135
|
+
}, ({ domain }) => call(() => ship.domains.records(domain)));
|
|
136
|
+
server.registerTool('domains_dns', {
|
|
137
|
+
description: 'Look up the DNS provider for a domain (e.g. Cloudflare, Namecheap). Helps the user know where to configure their DNS records.',
|
|
138
|
+
annotations: READ,
|
|
139
|
+
inputSchema: {
|
|
140
|
+
domain: z
|
|
141
|
+
.string()
|
|
142
|
+
.describe('Domain name to look up DNS provider for (e.g. "www.example.com")'),
|
|
143
|
+
},
|
|
144
|
+
}, ({ domain }) => call(() => ship.domains.dns(domain)));
|
|
145
|
+
server.registerTool('domains_share', {
|
|
146
|
+
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.',
|
|
147
|
+
annotations: READ,
|
|
148
|
+
inputSchema: {
|
|
149
|
+
domain: z
|
|
150
|
+
.string()
|
|
151
|
+
.describe('Domain name to generate a share link for. Must be a domain previously created with domains_set.'),
|
|
152
|
+
},
|
|
153
|
+
}, ({ domain }) => call(() => ship.domains.share(domain)));
|
|
154
|
+
server.registerTool('domains_validate', {
|
|
155
|
+
description: 'Check if a domain name is valid and available before creating it. Returns the normalized form and availability.',
|
|
156
|
+
annotations: READ,
|
|
157
|
+
inputSchema: {
|
|
158
|
+
domain: z
|
|
159
|
+
.string()
|
|
160
|
+
.describe('Domain name to check (e.g. "www.example.com"). Call before domains_set to check availability.'),
|
|
161
|
+
},
|
|
162
|
+
}, ({ domain }) => call(() => ship.domains.validate(domain)));
|
|
163
|
+
server.registerTool('domains_verify', {
|
|
164
|
+
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.',
|
|
165
|
+
annotations: WRITE,
|
|
166
|
+
inputSchema: {
|
|
167
|
+
domain: z
|
|
168
|
+
.string()
|
|
169
|
+
.describe('Domain name to verify DNS for. Must be a domain previously created with domains_set.'),
|
|
170
|
+
},
|
|
171
|
+
}, ({ domain }) => call(() => ship.domains.verify(domain)));
|
|
172
|
+
server.registerTool('domains_delete', {
|
|
173
|
+
description: 'Permanently delete a domain. You MUST confirm with the user before calling this tool, referencing the domain name.',
|
|
174
|
+
annotations: DESTRUCTIVE,
|
|
175
|
+
inputSchema: {
|
|
176
|
+
domain: z.string().describe('Domain name to delete (e.g. "www.example.com")'),
|
|
177
|
+
},
|
|
178
|
+
}, ({ domain }) => call(() => ship.domains.delete(domain)));
|
|
179
|
+
// Account
|
|
180
|
+
server.registerTool('whoami', {
|
|
181
|
+
description: 'Show authenticated account details including email, plan, and usage.',
|
|
182
|
+
annotations: READ,
|
|
183
|
+
}, () => call(() => ship.whoami()));
|
|
184
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary both transports speak.
|
|
3
|
+
*
|
|
4
|
+
* `@shipstatic/mcp` (stdio) and the hosted Streamable-HTTP server are one
|
|
5
|
+
* product with two doors in. Everything an agent observes that is NOT forced
|
|
6
|
+
* apart by the transport lives here and is IMPORTED by both — because a fact
|
|
7
|
+
* with two owners is a fact that drifts. This pair kept ten such strings
|
|
8
|
+
* byte-identical by hand for a year, and the hand slipped: a tool description
|
|
9
|
+
* diverged unnoticed, a one-word correction had to be applied at three sites,
|
|
10
|
+
* and a test mock invented constraint numbers production never used. A
|
|
11
|
+
* coordination table written in prose is a specification for drift, not a
|
|
12
|
+
* defence against it.
|
|
13
|
+
*
|
|
14
|
+
* What belongs here: anything true of a ShipStatic deploy regardless of how
|
|
15
|
+
* the bytes arrived. What does not, and why:
|
|
16
|
+
*
|
|
17
|
+
* - **The file-input schema.** A filesystem path here, inline content there:
|
|
18
|
+
* Workers has no filesystem. Structurally forced apart.
|
|
19
|
+
* - **Tool descriptions.** Deliberately rewritten hosted-side for an
|
|
20
|
+
* Apps-SDK caller that must be told not to base64-encode text — a failure
|
|
21
|
+
* mode the filesystem path does not have.
|
|
22
|
+
* - **Anything Apps-SDK** (widget, `_meta`, `outputSchema`): hosted-only by
|
|
23
|
+
* nature.
|
|
24
|
+
*
|
|
25
|
+
* Each of those is recorded in `cloudflare/mcp/CLAUDE.md`'s divergence table.
|
|
26
|
+
* Everything else should be here, and adding a shared fact anywhere else is
|
|
27
|
+
* how the next year's drift starts.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* MCP tool annotations by kind of operation. An agent reads these to decide
|
|
31
|
+
* whether it may call speculatively (`readOnlyHint`), whether a retry is free
|
|
32
|
+
* (`idempotentHint`), and whether it must confirm with the user first
|
|
33
|
+
* (`destructiveHint`).
|
|
34
|
+
*
|
|
35
|
+
* **`CREATE` carries no `idempotentHint`, deliberately.** A deploy creates a
|
|
36
|
+
* new deployment on every call. `idempotencyKey` makes a retry replay the
|
|
37
|
+
* original instead — but that property is conditional on an argument the
|
|
38
|
+
* caller may not pass, while the annotation is static per tool. Advertising it
|
|
39
|
+
* would promise every agent that any retry is free, which is exactly false for
|
|
40
|
+
* the keyless caller, and an annotation an agent trusts wrongly is worse than
|
|
41
|
+
* one it never reads.
|
|
42
|
+
*/
|
|
43
|
+
export declare const ANNOTATIONS: {
|
|
44
|
+
readonly READ: {
|
|
45
|
+
readonly openWorldHint: true;
|
|
46
|
+
readonly readOnlyHint: true;
|
|
47
|
+
readonly destructiveHint: false;
|
|
48
|
+
readonly idempotentHint: true;
|
|
49
|
+
};
|
|
50
|
+
readonly CREATE: {
|
|
51
|
+
readonly openWorldHint: true;
|
|
52
|
+
readonly readOnlyHint: false;
|
|
53
|
+
readonly destructiveHint: false;
|
|
54
|
+
};
|
|
55
|
+
readonly WRITE: {
|
|
56
|
+
readonly openWorldHint: true;
|
|
57
|
+
readonly readOnlyHint: false;
|
|
58
|
+
readonly destructiveHint: false;
|
|
59
|
+
readonly idempotentHint: true;
|
|
60
|
+
};
|
|
61
|
+
readonly DESTRUCTIVE: {
|
|
62
|
+
readonly openWorldHint: true;
|
|
63
|
+
readonly readOnlyHint: false;
|
|
64
|
+
readonly destructiveHint: true;
|
|
65
|
+
readonly idempotentHint: true;
|
|
66
|
+
};
|
|
67
|
+
};
|
|
68
|
+
/**
|
|
69
|
+
* Deploy-parameter descriptions shared by both transports.
|
|
70
|
+
*
|
|
71
|
+
* The numbers interpolate from `@shipstatic/types` rather than being written
|
|
72
|
+
* out, so a platform constraint change reaches every agent-facing string
|
|
73
|
+
* without anyone editing prose — the same reason the API and the SDK import
|
|
74
|
+
* them instead of restating them.
|
|
75
|
+
*/
|
|
76
|
+
export declare const PARAM_DESCRIPTIONS: {
|
|
77
|
+
readonly labels: "Labels for organizing deployments (e.g. [\"production\", \"v1.2\"]). Lowercase, 3-25 chars, allows . _ - separators. Up to 10.";
|
|
78
|
+
readonly password: "Optional password to gate the deployment behind an unlock prompt (6–128 characters; whitespace significant). Visitors must enter this password before viewing the site, including on any custom domains pointing at it.";
|
|
79
|
+
};
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vocabulary both transports speak.
|
|
3
|
+
*
|
|
4
|
+
* `@shipstatic/mcp` (stdio) and the hosted Streamable-HTTP server are one
|
|
5
|
+
* product with two doors in. Everything an agent observes that is NOT forced
|
|
6
|
+
* apart by the transport lives here and is IMPORTED by both — because a fact
|
|
7
|
+
* with two owners is a fact that drifts. This pair kept ten such strings
|
|
8
|
+
* byte-identical by hand for a year, and the hand slipped: a tool description
|
|
9
|
+
* diverged unnoticed, a one-word correction had to be applied at three sites,
|
|
10
|
+
* and a test mock invented constraint numbers production never used. A
|
|
11
|
+
* coordination table written in prose is a specification for drift, not a
|
|
12
|
+
* defence against it.
|
|
13
|
+
*
|
|
14
|
+
* What belongs here: anything true of a ShipStatic deploy regardless of how
|
|
15
|
+
* the bytes arrived. What does not, and why:
|
|
16
|
+
*
|
|
17
|
+
* - **The file-input schema.** A filesystem path here, inline content there:
|
|
18
|
+
* Workers has no filesystem. Structurally forced apart.
|
|
19
|
+
* - **Tool descriptions.** Deliberately rewritten hosted-side for an
|
|
20
|
+
* Apps-SDK caller that must be told not to base64-encode text — a failure
|
|
21
|
+
* mode the filesystem path does not have.
|
|
22
|
+
* - **Anything Apps-SDK** (widget, `_meta`, `outputSchema`): hosted-only by
|
|
23
|
+
* nature.
|
|
24
|
+
*
|
|
25
|
+
* Each of those is recorded in `cloudflare/mcp/CLAUDE.md`'s divergence table.
|
|
26
|
+
* Everything else should be here, and adding a shared fact anywhere else is
|
|
27
|
+
* how the next year's drift starts.
|
|
28
|
+
*/
|
|
29
|
+
import { LABEL_CONSTRAINTS, PASSWORD_CONSTRAINTS } from '@shipstatic/ship';
|
|
30
|
+
const OPEN_WORLD = { openWorldHint: true };
|
|
31
|
+
/**
|
|
32
|
+
* MCP tool annotations by kind of operation. An agent reads these to decide
|
|
33
|
+
* whether it may call speculatively (`readOnlyHint`), whether a retry is free
|
|
34
|
+
* (`idempotentHint`), and whether it must confirm with the user first
|
|
35
|
+
* (`destructiveHint`).
|
|
36
|
+
*
|
|
37
|
+
* **`CREATE` carries no `idempotentHint`, deliberately.** A deploy creates a
|
|
38
|
+
* new deployment on every call. `idempotencyKey` makes a retry replay the
|
|
39
|
+
* original instead — but that property is conditional on an argument the
|
|
40
|
+
* caller may not pass, while the annotation is static per tool. Advertising it
|
|
41
|
+
* would promise every agent that any retry is free, which is exactly false for
|
|
42
|
+
* the keyless caller, and an annotation an agent trusts wrongly is worse than
|
|
43
|
+
* one it never reads.
|
|
44
|
+
*/
|
|
45
|
+
export const ANNOTATIONS = {
|
|
46
|
+
READ: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, ...OPEN_WORLD },
|
|
47
|
+
CREATE: { readOnlyHint: false, destructiveHint: false, ...OPEN_WORLD },
|
|
48
|
+
WRITE: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, ...OPEN_WORLD },
|
|
49
|
+
DESTRUCTIVE: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, ...OPEN_WORLD },
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Deploy-parameter descriptions shared by both transports.
|
|
53
|
+
*
|
|
54
|
+
* The numbers interpolate from `@shipstatic/types` rather than being written
|
|
55
|
+
* out, so a platform constraint change reaches every agent-facing string
|
|
56
|
+
* without anyone editing prose — the same reason the API and the SDK import
|
|
57
|
+
* them instead of restating them.
|
|
58
|
+
*/
|
|
59
|
+
export const PARAM_DESCRIPTIONS = {
|
|
60
|
+
labels: `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}.`,
|
|
61
|
+
password: `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.`,
|
|
62
|
+
};
|
package/package.json
CHANGED
|
@@ -1,13 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipstatic/mcp",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.3",
|
|
4
4
|
"mcpName": "com.shipstatic/mcp",
|
|
5
5
|
"description": "ShipStatic MCP — deploy static websites from AI agents. Full toolset incl. custom domains. Free hosted endpoint at mcp.shipstatic.com — no install.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "./dist/index.js",
|
|
8
8
|
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"default": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./package.json": "./package.json"
|
|
15
|
+
},
|
|
9
16
|
"bin": {
|
|
10
|
-
"shipstatic-mcp": "./dist/
|
|
17
|
+
"shipstatic-mcp": "./dist/bin.js"
|
|
11
18
|
},
|
|
12
19
|
"scripts": {
|
|
13
20
|
"build": "tsc",
|