@typeroll/mcp-server 0.7.4

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/index.js ADDED
@@ -0,0 +1,114 @@
1
+ #!/usr/bin/env node
2
+ // Typeroll MCP server — stdio entry point.
3
+ //
4
+ // Reads two env vars at startup:
5
+ // TYPEROLL_API_URL — base URL of the portal (e.g. https://app.typeroll.com)
6
+ // TYPEROLL_API_KEY — a typeroll_live_... bearer token from /app/sites/{id}/settings/api-keys
7
+ //
8
+ // Optionally:
9
+ // TYPEROLL_SITE_ID — pre-set the site id. If omitted we discover it by
10
+ // calling GET /v1/sites at startup (the v1 keys are
11
+ // per-site so that endpoint always returns exactly one).
12
+ //
13
+ // Each tool wraps a single REST endpoint. The MCP server itself does no
14
+ // business logic — that all lives in the portal. This keeps the package
15
+ // boring to maintain and lets the customer's self-hosted portal serve the
16
+ // same MCP without needing two implementations to stay in sync.
17
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
19
+ import { TyperollClient } from './client.js';
20
+ import { pageTools } from './tools/pages.js';
21
+ import { partialTools } from './tools/partials.js';
22
+ import { collectionTools } from './tools/collections.js';
23
+ import { mediaTools } from './tools/media.js';
24
+ import { redirectTools } from './tools/redirects.js';
25
+ import { formTools } from './tools/forms.js';
26
+ import { searchTools } from './tools/search.js';
27
+ import { bulkTools } from './tools/bulk.js';
28
+ import { versionTools } from './tools/versions.js';
29
+ import { deployTools } from './tools/deploy.js';
30
+ import { previewTools } from './tools/preview.js';
31
+ import { blockTypeTools } from './tools/block-types.js';
32
+ import { pageBlockTools } from './tools/page-blocks.js';
33
+ import { settingsTools } from './tools/settings.js';
34
+ import { siteTools } from './tools/sites.js';
35
+ const VERSION = '0.1.0';
36
+ async function resolveSiteId(client) {
37
+ const fromEnv = process.env.TYPEROLL_SITE_ID?.trim();
38
+ if (fromEnv)
39
+ return fromEnv;
40
+ // Per-site keys → exactly one site. We fetch it so the agent doesn't
41
+ // have to know its own site id ahead of time.
42
+ const res = await client.rootGet('sites');
43
+ if (!res.sites || res.sites.length === 0) {
44
+ throw new Error('No sites returned for this API key. Check the key is valid.');
45
+ }
46
+ if (res.sites.length > 1) {
47
+ throw new Error(`This key authorises ${res.sites.length} sites; set TYPEROLL_SITE_ID to pick one.`);
48
+ }
49
+ return res.sites[0].id;
50
+ }
51
+ function bail(message) {
52
+ console.error(`typeroll-mcp: ${message}`);
53
+ process.exit(1);
54
+ }
55
+ async function main() {
56
+ const apiUrl = process.env.TYPEROLL_API_URL?.trim();
57
+ const apiKey = process.env.TYPEROLL_API_KEY?.trim();
58
+ if (!apiUrl)
59
+ bail('TYPEROLL_API_URL is not set. Point it at your Typeroll portal (e.g. https://app.typeroll.com).');
60
+ if (!apiKey)
61
+ bail('TYPEROLL_API_KEY is not set. Create a key in /app/sites/{siteId}/settings/api-keys and copy it once.');
62
+ if (!apiKey.startsWith('typeroll_live_')) {
63
+ bail('TYPEROLL_API_KEY does not look like a Typeroll key (expected typeroll_live_... prefix).');
64
+ }
65
+ const client = new TyperollClient({ baseUrl: apiUrl, apiKey });
66
+ let siteId;
67
+ try {
68
+ siteId = await resolveSiteId(client);
69
+ }
70
+ catch (e) {
71
+ bail(`Failed to discover site: ${e instanceof Error ? e.message : String(e)}`);
72
+ }
73
+ const deps = { client, siteId };
74
+ const server = new McpServer({ name: 'typeroll', version: VERSION }, { capabilities: { tools: {} } });
75
+ const allTools = [
76
+ ...siteTools,
77
+ ...pageTools,
78
+ ...partialTools,
79
+ ...blockTypeTools,
80
+ ...pageBlockTools,
81
+ ...collectionTools,
82
+ ...mediaTools,
83
+ ...redirectTools,
84
+ ...formTools,
85
+ ...settingsTools,
86
+ ...searchTools,
87
+ ...bulkTools,
88
+ ...versionTools,
89
+ ...deployTools,
90
+ ...previewTools,
91
+ ];
92
+ // The SDK's registerTool signature has very deep generic constraints
93
+ // (ZodRawShape × AnySchema × annotations) that TypeScript can't unify
94
+ // when we iterate heterogeneous tools at runtime. We cast the bag once
95
+ // here and rely on our own ToolDef contract for type safety.
96
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
97
+ const register = server.registerTool.bind(server);
98
+ for (const tool of allTools) {
99
+ register(tool.name, {
100
+ description: tool.description,
101
+ ...(tool.inputSchema ? { inputSchema: tool.inputSchema } : {}),
102
+ },
103
+ // The SDK's callback gets the args object (when inputSchema is set) or
104
+ // no args (when it isn't). Both are valid for our handler signature.
105
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
106
+ async (args) => tool.handler(args ?? {}, deps));
107
+ }
108
+ const transport = new StdioServerTransport();
109
+ await server.connect(transport);
110
+ }
111
+ main().catch((err) => {
112
+ console.error('typeroll-mcp fatal:', err);
113
+ process.exit(1);
114
+ });
@@ -0,0 +1,86 @@
1
+ // Block-type tools. Surface is wired in now so the future block editor
2
+ // drops in without breaking integrations.
3
+ import { z } from 'zod';
4
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
5
+ function v(version) {
6
+ return version ? { version } : undefined;
7
+ }
8
+ export const blockTypeTools = [
9
+ {
10
+ name: 'export_block_types',
11
+ description: 'Pack custom block types into a .tcblocks zip (base64). When `ids` is omitted, every user-created block type is bundled. Useful for moving blocks between sites or backing them up — the returned zip is a portable package the import tool reads back.',
12
+ inputSchema: {
13
+ ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every user-created block.'),
14
+ name: z.string().optional().describe('Package name (default: {site}-blocks).'),
15
+ version: z.string().optional().describe('Package version (default: 1.0.0).'),
16
+ version_branch: versionParam,
17
+ },
18
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
19
+ const query = {};
20
+ if (args.ids?.length)
21
+ query.ids = args.ids.join(',');
22
+ if (args.name)
23
+ query.name = args.name;
24
+ if (args.version)
25
+ query.version = args.version;
26
+ if (args.version_branch)
27
+ query.version = args.version_branch;
28
+ const res = await client.get(siteId, 'blocks/export', query);
29
+ return ok(res);
30
+ }),
31
+ },
32
+ {
33
+ name: 'import_block_types',
34
+ description: 'Install block types from a .tcblocks package. Provide the zip as base64. Re-importing the same package overwrites existing blocks with the same id (package_name/block_name). Each imported BlockType may include arbitrary CSS and JS — the caller is responsible for trusting the source. Returns counts of created vs updated docs.',
35
+ inputSchema: {
36
+ zip_base64: z.string().describe('Base64-encoded .tcblocks zip.'),
37
+ version: versionParam,
38
+ },
39
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
40
+ const v = args.version ? { version: args.version } : undefined;
41
+ const res = await client.post(siteId, 'blocks/import', { zip_base64: args.zip_base64 }, v);
42
+ return ok(res);
43
+ }),
44
+ },
45
+ {
46
+ name: 'list_block_types',
47
+ description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), custom blocks (origin: "user", created in the portal), and third-party blocks (origin: "third_party", imported from .tcblocks packages). Each entry includes id, label, category, container/slot info, origin, and the full schema (fields with name/type/label/required/options/default). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site.',
48
+ inputSchema: {
49
+ include_core: z.boolean().optional().describe('Default true. Set false to get only custom + third-party (rarely needed).'),
50
+ version: versionParam,
51
+ },
52
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
53
+ const query = {};
54
+ if (args.version)
55
+ query.version = args.version;
56
+ if (args.include_core === false)
57
+ query.include_core = 'false';
58
+ const res = await client.get(siteId, 'block-types', query);
59
+ return ok(res);
60
+ }),
61
+ },
62
+ {
63
+ name: 'read_block_type',
64
+ description: "Full schema for one block type. Returns the BlockType doc with template, styles, optional script, and the field-definitions array. Use this when list_block_types' summary isn't enough — e.g. before add_block on a custom block whose data fields you haven't seen.",
65
+ inputSchema: {
66
+ type_id: z.string(),
67
+ version: versionParam,
68
+ },
69
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
70
+ const res = await client.get(siteId, `block-types/${encodeURIComponent(args.type_id)}`, v(args.version));
71
+ return ok(res);
72
+ }),
73
+ },
74
+ {
75
+ name: 'find_pages_using_block_type',
76
+ description: 'Pages whose block tree contains this block type. Empty for HTML-mode sites (the default today); fills in once block-mode pages exist.',
77
+ inputSchema: {
78
+ type_id: z.string(),
79
+ version: versionParam,
80
+ },
81
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
82
+ const res = await client.get(siteId, `block-types/${encodeURIComponent(args.type_id)}/usage`, v(args.version));
83
+ return ok(res);
84
+ }),
85
+ },
86
+ ];
@@ -0,0 +1,24 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ function v(version) {
4
+ return version ? { version } : undefined;
5
+ }
6
+ export const bulkTools = [
7
+ {
8
+ name: 'bulk_replace_text',
9
+ description: 'Replace a literal substring or regex across pages in one call. ALWAYS run with dry_run=true first and show the sample_diffs to the user before running the real call. Writes go through the normal pipeline (SEO transforms + revision snapshots). Response shape: { dry_run, updated, total_matches, pages_with_matches, sample_diffs_shown, additional_pages_with_matches, sample_diffs[], skipped (deprecated) }.',
10
+ inputSchema: {
11
+ pattern: z.string().min(1).describe('Literal substring (default) or JS regex source if regex=true.'),
12
+ replacement: z.string(),
13
+ regex: z.boolean().optional().describe('Treat pattern as a regex source. Always case-insensitive + global.'),
14
+ page_ids: z.array(z.string()).optional().describe('Restrict to these page ids. Omit to apply to every matching page.'),
15
+ dry_run: z.boolean().optional(),
16
+ version: versionParam,
17
+ },
18
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
19
+ const { version, ...body } = args;
20
+ const res = await client.post(siteId, 'bulk-replace', body, v(version));
21
+ return ok(res);
22
+ }),
23
+ },
24
+ ];
@@ -0,0 +1,197 @@
1
+ // Collections + items (blog, team, events, products, custom).
2
+ import { z } from 'zod';
3
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
4
+ function v(version) {
5
+ return version ? { version } : undefined;
6
+ }
7
+ export const collectionTools = [
8
+ {
9
+ name: 'create_collection',
10
+ description: 'Create a new content collection with a field schema and (optionally) per-item URLs. Pass `route_template` (e.g. "/restaurants/{slug}") to give each published item its own URL — omit to default to "/{name}/{slug}", set to "" to disable per-item URLs entirely.',
11
+ inputSchema: {
12
+ name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
13
+ label_singular: z.string().min(1),
14
+ label_plural: z.string().min(1),
15
+ icon: z.string().optional(),
16
+ fields: z
17
+ .array(z.object({
18
+ name: z.string(),
19
+ label: z.string(),
20
+ type: z.string(),
21
+ required: z.boolean().optional(),
22
+ default: z.unknown().optional(),
23
+ options: z.array(z.string()).optional(),
24
+ }).passthrough())
25
+ .min(1),
26
+ slug_field: z.string().optional(),
27
+ sort_field: z.string().optional(),
28
+ sort_dir: z.enum(['asc', 'desc']).optional(),
29
+ route_template: z.string().optional(),
30
+ item_template_html: z.string().optional(),
31
+ version: versionParam,
32
+ },
33
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
34
+ const { version, ...body } = args;
35
+ const res = await client.post(siteId, 'collections', body, v(version));
36
+ return ok(res);
37
+ }),
38
+ },
39
+ {
40
+ name: 'update_collection_schema',
41
+ description: 'PATCH a collection\'s schema or routing. Pass only the fields you want to change. Renaming a field would orphan existing item data — adding new fields is safe, removing fields is silently allowed but item docs keep the dropped data.',
42
+ inputSchema: {
43
+ name: z.string(),
44
+ patch: z
45
+ .object({
46
+ label_singular: z.string().optional(),
47
+ label_plural: z.string().optional(),
48
+ icon: z.string().optional(),
49
+ fields: z.array(z.record(z.unknown())).optional(),
50
+ slug_field: z.string().optional(),
51
+ sort_field: z.string().optional(),
52
+ sort_dir: z.enum(['asc', 'desc']).optional(),
53
+ route_template: z.string().optional(),
54
+ item_template_html: z.string().optional(),
55
+ })
56
+ .passthrough(),
57
+ version: versionParam,
58
+ },
59
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
60
+ const res = await client.patch(siteId, `collections/${encodeURIComponent(args.name)}`, args.patch, v(args.version));
61
+ return ok(res);
62
+ }),
63
+ },
64
+ {
65
+ name: 'delete_collection',
66
+ description: 'Delete a collection AND every item in it. Destructive; requires `confirm: true`. Get-the-user-to-confirm workflow recommended.',
67
+ inputSchema: {
68
+ name: z.string(),
69
+ confirm: z.literal(true).describe('Must be true to actually delete.'),
70
+ version: versionParam,
71
+ },
72
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
73
+ const res = await client.del(siteId, `collections/${encodeURIComponent(args.name)}`, { confirm: 'true', ...v(args.version) });
74
+ return ok(res);
75
+ }),
76
+ },
77
+ {
78
+ name: 'list_collections',
79
+ description: 'List content collections on the site (name, label, icon, field schemas).',
80
+ inputSchema: { version: versionParam },
81
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
82
+ const res = await client.get(siteId, 'collections', v(args.version));
83
+ return ok(res);
84
+ }),
85
+ },
86
+ {
87
+ name: 'read_collection',
88
+ description: 'Fetch one collection\'s schema (label, fields, icon).',
89
+ inputSchema: { name: z.string(), version: versionParam },
90
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
91
+ const res = await client.get(siteId, `collections/${encodeURIComponent(args.name)}`, v(args.version));
92
+ return ok(res);
93
+ }),
94
+ },
95
+ {
96
+ name: 'list_collection_items',
97
+ description: 'List items in a collection with status filter + cursor pagination (cap 200 per page). Defaults to summary mode (richtext/textarea fields replaced with a byte-count hint) so a 200-item directory listing stays compact; pass include_richtext: true if you actually need full bodies.',
98
+ inputSchema: {
99
+ collection: z.string(),
100
+ status: z.enum(['draft', 'published', 'all']).optional(),
101
+ limit: z.number().int().min(1).max(200).optional(),
102
+ cursor: z.string().optional(),
103
+ include_richtext: z.boolean().optional().describe('Include full richtext/textarea field values inline. Default false.'),
104
+ version: versionParam,
105
+ },
106
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
107
+ const { collection, version, include_richtext, ...query } = args;
108
+ const res = await client.get(siteId, `collections/${encodeURIComponent(collection)}/items`, { ...query, ...(include_richtext ? {} : { summary: 'true' }), ...v(version) });
109
+ return ok(res);
110
+ }),
111
+ },
112
+ {
113
+ name: 'batch_read_collection_items',
114
+ description: 'Read up to 200 collection items in one call. Returns per-id found/not-found.',
115
+ inputSchema: {
116
+ collection: z.string(),
117
+ item_ids: z.array(z.string()).min(1).max(200),
118
+ version: versionParam,
119
+ },
120
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
121
+ const res = await client.post(siteId, `collections/${encodeURIComponent(args.collection)}/items/batch-read`, { item_ids: args.item_ids }, v(args.version));
122
+ return ok(res);
123
+ }),
124
+ },
125
+ {
126
+ name: 'read_collection_item',
127
+ description: 'Fetch a single item with all fields.',
128
+ inputSchema: { collection: z.string(), item_id: z.string(), version: versionParam },
129
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
130
+ const res = await client.get(siteId, `collections/${encodeURIComponent(args.collection)}/items/${encodeURIComponent(args.item_id)}`, v(args.version));
131
+ return ok(res);
132
+ }),
133
+ },
134
+ {
135
+ name: 'create_collection_item',
136
+ description: 'Create an item. Fields outside the collection schema are silently dropped — call list_collections first if you\'re unsure what fields exist.',
137
+ inputSchema: {
138
+ collection: z.string(),
139
+ fields: z.record(z.unknown()),
140
+ status: z.enum(['draft', 'published']).optional(),
141
+ version: versionParam,
142
+ },
143
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
144
+ const { collection, version, ...body } = args;
145
+ const res = await client.post(siteId, `collections/${encodeURIComponent(collection)}/items`, body, v(version));
146
+ return ok(res);
147
+ }),
148
+ },
149
+ {
150
+ name: 'update_collection_item',
151
+ description: 'Update a collection item. Fields outside the schema are dropped.',
152
+ inputSchema: {
153
+ collection: z.string(),
154
+ item_id: z.string(),
155
+ fields: z.record(z.unknown()).optional(),
156
+ status: z.enum(['draft', 'published']).optional(),
157
+ version: versionParam,
158
+ },
159
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
160
+ const { collection, item_id, version, ...body } = args;
161
+ const res = await client.patch(siteId, `collections/${encodeURIComponent(collection)}/items/${encodeURIComponent(item_id)}`, body, v(version));
162
+ return ok(res);
163
+ }),
164
+ },
165
+ {
166
+ name: 'regenerate_collection_listing',
167
+ description: 'Refresh a hand-managed listing of collection items on a page. The agent inserts a marker pair `<!-- typeroll:listing:{collection} -->` ... `<!-- /typeroll:listing:{collection} -->` into a page body once; from then on this tool fills the section between the markers with the current published items, sorted by the collection\'s sort_field. Pass `item_template` to customize per-item HTML — {{field}} substitutes HTML-escaped, {{{field}}} raw, {{url}} resolves through the collection\'s route_template. Defaults to a basic <ul> of links.',
168
+ inputSchema: {
169
+ collection: z.string(),
170
+ page_id: z.string(),
171
+ item_template: z.string().optional()
172
+ .describe('Per-item HTML template. Default: <li><a href="{{url}}">{{title}}</a></li>'),
173
+ wrap_open: z.string().optional().describe('Override the opening wrap element. Default: <ul class="tr-listing">'),
174
+ wrap_close: z.string().optional().describe('Override the closing wrap element. Default: </ul>'),
175
+ empty_html: z.string().optional().describe('Rendered when no items exist.'),
176
+ sort_field: z.string().optional().describe('Override the collection\'s default sort_field.'),
177
+ sort_dir: z.enum(['asc', 'desc']).optional(),
178
+ status_filter: z.enum(['published', 'all']).optional().describe('Default: published.'),
179
+ limit: z.number().int().min(1).max(500).optional(),
180
+ version: versionParam,
181
+ },
182
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
183
+ const { collection, version, ...body } = args;
184
+ const res = await client.post(siteId, `collections/${encodeURIComponent(collection)}/regenerate-listing`, body, v(version));
185
+ return ok(res);
186
+ }),
187
+ },
188
+ {
189
+ name: 'delete_collection_item',
190
+ description: 'Delete a collection item.',
191
+ inputSchema: { collection: z.string(), item_id: z.string(), version: versionParam },
192
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
193
+ const res = await client.del(siteId, `collections/${encodeURIComponent(args.collection)}/items/${encodeURIComponent(args.item_id)}`, v(args.version));
194
+ return ok(res);
195
+ }),
196
+ },
197
+ ];
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { ok, withErrorBoundary, versionParam } from './helpers.js';
3
+ function v(version) {
4
+ return version ? { version } : undefined;
5
+ }
6
+ export const deployTools = [
7
+ {
8
+ name: 'trigger_deploy',
9
+ description: "Enqueue a deploy of the currently active version. Returns a job_id immediately; poll get_deploy_status until the status leaves queued/running. With `dry_run: true`, the deploy runs through every step EXCEPT the hosting-adapter upload — useful for validating a structural change (new collection routes, schema bump, mode switch) without risking the live site. A dry_run job that succeeds proves the build is clean; one that fails returns the same astro stack-trace in get_deploy_status.error as a real deploy would.",
10
+ inputSchema: {
11
+ environment: z.enum(['production', 'staging']).optional(),
12
+ dry_run: z.boolean().optional().describe('Run the build but skip the CDN upload. The job ends in succeeded/failed the same way a real deploy does.'),
13
+ version: versionParam,
14
+ },
15
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
16
+ const { version, ...body } = args;
17
+ const res = await client.post(siteId, 'deploy', body, v(version));
18
+ return ok(res);
19
+ }),
20
+ },
21
+ {
22
+ name: 'list_deploys',
23
+ description: 'Recent deploy jobs, newest first (capped at 50).',
24
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
25
+ const res = await client.get(siteId, 'deploys');
26
+ return ok(res);
27
+ }),
28
+ },
29
+ {
30
+ name: 'get_deploy_status',
31
+ description: "Status of a single deploy job. Returns { job, queued_for_seconds, queue_timeout_seconds }. job.status is one of queued | running | succeeded | failed. Polling cadence: ~5s for queued/running. A job stuck in 'queued' for longer than queue_timeout_seconds (default 300s) auto-flips to failed with phase='queue_timeout' — so a single poll past that timestamp returns the terminal state and you can stop polling.",
32
+ inputSchema: { job_id: z.string() },
33
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
34
+ const res = await client.get(siteId, `deploys/${encodeURIComponent(args.job_id)}`);
35
+ return ok(res);
36
+ }),
37
+ },
38
+ ];
@@ -0,0 +1,98 @@
1
+ // Forms — agents can create/update/delete forms, and read submissions.
2
+ // The customer-facing submit endpoint is HMAC-signed and lives at
3
+ // /api/forms/submit; the in-portal chat's `get_form_embed` tool produces
4
+ // the snippet customers paste into their pages. This file is the
5
+ // management surface.
6
+ import { z } from 'zod';
7
+ import { ok, withErrorBoundary } from './helpers.js';
8
+ const fieldSchema = z.object({
9
+ name: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
10
+ type: z.enum([
11
+ 'text', 'email', 'tel', 'url', 'number', 'textarea',
12
+ 'select', 'checkbox', 'radio', 'hidden', 'gdpr_consent',
13
+ ]),
14
+ label: z.string(),
15
+ required: z.boolean().optional(),
16
+ placeholder: z.string().optional(),
17
+ options: z.array(z.string()).optional().describe('Required for type "select" and "radio".'),
18
+ default: z.unknown().optional(),
19
+ });
20
+ export const formTools = [
21
+ {
22
+ name: 'list_forms',
23
+ description: 'List every form defined on the site with its full field schema.',
24
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
25
+ const res = await client.get(siteId, 'forms');
26
+ return ok(res);
27
+ }),
28
+ },
29
+ {
30
+ name: 'read_form',
31
+ description: 'Read one form by id, including fields, submit_text, and success_message.',
32
+ inputSchema: { form_id: z.string() },
33
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
34
+ const res = await client.get(siteId, `forms/${encodeURIComponent(args.form_id)}`);
35
+ return ok(res);
36
+ }),
37
+ },
38
+ {
39
+ name: 'create_form',
40
+ description: 'Create a form. Each field has { name, type, label, required?, placeholder?, options? }. Allowed types: text, email, tel, url, number, textarea, select, checkbox, radio, hidden, gdpr_consent. The form can then be embedded on any page — use get_form_embed (in-portal chat) or render the form yourself by reading the schema.',
41
+ inputSchema: {
42
+ id: z.string().regex(/^[a-z][a-z0-9_-]{0,62}$/),
43
+ name: z.string().min(1),
44
+ fields: z.array(fieldSchema).min(1),
45
+ submit_text: z.string().optional(),
46
+ success_message: z.string().optional(),
47
+ actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
48
+ },
49
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
50
+ const res = await client.post(siteId, 'forms', args);
51
+ return ok(res);
52
+ }),
53
+ },
54
+ {
55
+ name: 'update_form',
56
+ description: 'Patch a form. Provide only the fields you want to change. Passing `fields` replaces the entire field list (so reorder + rename + drop happens in one call). The existing form_id is immutable.',
57
+ inputSchema: {
58
+ form_id: z.string(),
59
+ patch: z.object({
60
+ name: z.string().optional(),
61
+ fields: z.array(fieldSchema).optional(),
62
+ submit_text: z.string().optional(),
63
+ success_message: z.string().optional(),
64
+ actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
65
+ }),
66
+ },
67
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
68
+ const res = await client.patch(siteId, `forms/${encodeURIComponent(args.form_id)}`, args.patch);
69
+ return ok(res);
70
+ }),
71
+ },
72
+ {
73
+ name: 'delete_form',
74
+ description: 'Delete a form. Existing submissions are preserved by default — pass delete_submissions: true to also drop them.',
75
+ inputSchema: {
76
+ form_id: z.string(),
77
+ delete_submissions: z.boolean().optional(),
78
+ },
79
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
80
+ const res = await client.del(siteId, `forms/${encodeURIComponent(args.form_id)}`, args.delete_submissions ? { delete_submissions: 'true' } : undefined);
81
+ return ok(res);
82
+ }),
83
+ },
84
+ {
85
+ name: 'list_form_submissions',
86
+ description: 'List submissions received for a form, newest first, cursor-paginated (cap 200 per page). Use this to help a customer triage inbound contact / lead submissions.',
87
+ inputSchema: {
88
+ form_id: z.string(),
89
+ limit: z.number().int().min(1).max(200).optional(),
90
+ cursor: z.string().optional(),
91
+ },
92
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
93
+ const { form_id, ...query } = args;
94
+ const res = await client.get(siteId, `forms/${encodeURIComponent(form_id)}/submissions`, query);
95
+ return ok(res);
96
+ }),
97
+ },
98
+ ];
@@ -0,0 +1,59 @@
1
+ // Shared helpers for tool handlers.
2
+ import { z } from 'zod';
3
+ import { ApiError } from '../client.js';
4
+ /** Format an arbitrary result as MCP tool output. Pretty-print JSON because
5
+ * Claude renders code blocks well and we want the model to see structured
6
+ * shapes, not blobby unfolded strings. */
7
+ export function ok(data) {
8
+ return {
9
+ content: [
10
+ {
11
+ type: 'text',
12
+ text: typeof data === 'string' ? data : JSON.stringify(data, null, 2),
13
+ },
14
+ ],
15
+ };
16
+ }
17
+ /** Turn an API error into a structured tool error. Preserves the status
18
+ * code so the agent can decide between "retry" and "give up". */
19
+ export function fail(err) {
20
+ if (err instanceof ApiError) {
21
+ return {
22
+ content: [
23
+ {
24
+ type: 'text',
25
+ text: JSON.stringify({
26
+ error: err.message,
27
+ status: err.status,
28
+ body: err.body,
29
+ }, null, 2),
30
+ },
31
+ ],
32
+ isError: true,
33
+ };
34
+ }
35
+ return {
36
+ content: [
37
+ { type: 'text', text: `Unexpected error: ${err instanceof Error ? err.message : String(err)}` },
38
+ ],
39
+ isError: true,
40
+ };
41
+ }
42
+ /** Convenience to wrap a handler so all API errors become structured tool
43
+ * errors instead of throwing — Claude Code displays the result more
44
+ * usefully when isError is set. */
45
+ export function withErrorBoundary(handler) {
46
+ return async (args, deps) => {
47
+ try {
48
+ return await handler(args, deps);
49
+ }
50
+ catch (err) {
51
+ return fail(err);
52
+ }
53
+ };
54
+ }
55
+ /** Shared zod fragment for the optional `version` parameter. */
56
+ export const versionParam = z
57
+ .string()
58
+ .optional()
59
+ .describe('Branch id to target. Omit for main.');