@typeroll/mcp-server 0.45.44 → 0.45.46

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/client.js CHANGED
@@ -44,7 +44,7 @@ export class TyperollClient {
44
44
  }
45
45
  return url.toString();
46
46
  }
47
- /** Top-level URL (no siteId prefix) — used by GET /v1/sites only. */
47
+ /** Top-level URL (no siteId prefix) — /v1/sites, /v1/organization/*, /v1/publishing/*. */
48
48
  rootUrl(path, query) {
49
49
  const cleanPath = path.replace(/^\/+/, '');
50
50
  const url = new URL(`${this.baseUrl}/api/v1/${cleanPath}`);
@@ -110,4 +110,18 @@ export class TyperollClient {
110
110
  rootPut(path, body) {
111
111
  return this.request('PUT', this.rootUrl(path), body);
112
112
  }
113
+ // ── Extension developer API ────────────────────────────────────────────
114
+ /** `/api/developer/{path}` — the Extension developer API used by the
115
+ * `typeroll extension` CLI. Requires an organization-scoped key. */
116
+ developer(method, path, body, query) {
117
+ const url = new URL(`${this.baseUrl}/api/developer/${path.replace(/^\/+/, '')}`);
118
+ for (const [k, v] of Object.entries(query ?? {})) {
119
+ if (v !== undefined && v !== null)
120
+ url.searchParams.set(k, String(v));
121
+ }
122
+ return this.request(method, url.toString(), body);
123
+ }
124
+ rootDelete(path, body) {
125
+ return this.request('DELETE', this.rootUrl(path), body);
126
+ }
113
127
  }
@@ -22,7 +22,7 @@ export function compactTools(catalog) {
22
22
  }),
23
23
  ...['read', 'write', 'admin'].map(effect => ({
24
24
  name: `call_${effect}_tool`, effect,
25
- description: effect === 'read' ? 'Call a discovered read-only tool with its exact arguments. Does not accept write/admin tools.' : `Call a discovered ${effect} tool. May ${effect === 'admin' ? 'change app configuration or activate releases' : 'save, delete, publish or otherwise change state'}. Inspect describe_tool first and follow the user’s authorization.`,
25
+ description: effect === 'read' ? 'Call a discovered read-only tool with its exact arguments. Does not accept write/admin tools.' : `Call a discovered ${effect} tool. May ${effect === 'admin' ? 'change app, email or block type configuration, send a test email or activate releases' : 'save, delete, publish or otherwise change state'}. Inspect describe_tool first and follow the user’s authorization.`,
26
26
  schema: { name: z.string().max(100), arguments: z.record(z.unknown()).default({}) },
27
27
  invoke: async (args) => {
28
28
  const tool = byName.get(String(args.name));
package/dist/server.js CHANGED
@@ -20,6 +20,7 @@ import { styleTools } from './tools/styles.js';
20
20
  import { reusableBlockTools } from './tools/reusable-blocks.js';
21
21
  import { migrationTools } from './tools/migration.js';
22
22
  import { formTools } from './tools/forms.js';
23
+ import { emailTools } from './tools/email.js';
23
24
  import { searchTools } from './tools/search.js';
24
25
  import { bulkTools } from './tools/bulk.js';
25
26
  import { versionTools } from './tools/versions.js';
@@ -32,8 +33,10 @@ import { settingsTools } from './tools/settings.js';
32
33
  import { siteTools } from './tools/sites.js';
33
34
  import { domainTools } from './tools/domain.js';
34
35
  import { appTools } from './tools/apps.js';
35
- import { extensionTools } from './tools/extensions.js';
36
+ import { extensionTools, developerExtensionTools } from './tools/extensions.js';
36
37
  import { skillTools } from './tools/skills.js';
38
+ import { accessTools } from './tools/access.js';
39
+ import { workflowTools } from './tools/workflows.js';
37
40
  import { fail } from './tools/helpers.js';
38
41
  import { VERSION } from './version.js';
39
42
  import { compactTools, COMPACT_INSTRUCTIONS } from './compact-tools.js';
@@ -47,7 +50,7 @@ const PERM_RANK = { read: 0, write: 1, admin: 2 };
47
50
  * gate then checks `effect <= sitePermission`.
48
51
  *
49
52
  * Naming convention is followed by the tool modules: read paths are
50
- * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*` / `check_*`; everything else
53
+ * `list_*` / `read_*` / `get_*` / `preview_*` / `search_*` / `check_*` / `find_*`; everything else
51
54
  * mutates.
52
55
  */
53
56
  function effectFor(name) {
@@ -60,8 +63,57 @@ function effectFor(name) {
60
63
  || name === 'list_extension_installations'
61
64
  || name === 'read_extension_installation'
62
65
  || name === 'update_extension_installation_config'
63
- || name === 'activate_extension_release')
66
+ || name === 'activate_extension_release'
67
+ // Same role as the portal: Settings, lifecycle and media purge are
68
+ // admin actions (the v1 routes enforce it as well).
69
+ || name === 'update_site_settings'
70
+ || name === 'archive_site'
71
+ || name === 'restore_site'
72
+ || name === 'purge_site_media'
73
+ || name === 'install_extension'
74
+ || name === 'set_extension_installation_status'
75
+ || name === 'uninstall_extension'
76
+ || name === 'pair_extension_issuer'
77
+ || name === 'read_extension_diagnostics'
78
+ // Branch lifecycle on main is site administration in the portal too.
79
+ || name === 'create_branch'
80
+ || name === 'merge_branch'
81
+ || name === 'delete_branch'
82
+ || name === 'reset_version'
83
+ // Developer-organization administration (org admin in the portal).
84
+ || name === 'update_developer_extension'
85
+ || name === 'save_extension_version'
86
+ || name === 'publish_extension_version'
87
+ || name === 'set_extension_version_lifecycle'
88
+ // Access and credential management: API keys, sharing, membership invites
89
+ // and organization publishing connections need admin, as in the portal.
90
+ || name === 'revoke_api_key'
91
+ || name === 'list_site_shares'
92
+ || name === 'share_site'
93
+ || name === 'update_site_share'
94
+ || name === 'revoke_site_share'
95
+ || name === 'revoke_organization_api_key'
96
+ || name === 'create_organization_invite'
97
+ || name === 'disconnect_organization_publishing_provider'
98
+ || name === 'connect_organization_cloudflare'
99
+ || name === 'prepare_organization_media_storage'
100
+ || name === 'save_organization_media_access'
101
+ || name === 'set_email_settings'
102
+ || name === 'delete_email_settings'
103
+ || name === 'send_test_email'
104
+ || name === 'set_incoming_email_forwarding'
105
+ // Block type authoring is site administration (the portal and the v1
106
+ // routes require admin); placing and editing blocks on pages stays write.
107
+ || name === 'create_block_type'
108
+ || name === 'update_block_type'
109
+ || name === 'delete_block_type'
110
+ || name === 'import_block_types')
64
111
  return 'admin';
112
+ // Compute an answer and write nothing, like the preview_* tools.
113
+ if (name === 'validate_block_type' || name.startsWith('find_'))
114
+ return 'read';
115
+ if (name === 'diff_version')
116
+ return 'read';
65
117
  if (name.startsWith('list_') ||
66
118
  name.startsWith('read_') ||
67
119
  name.startsWith('get_') ||
@@ -158,15 +210,19 @@ export function buildServer(options) {
158
210
  ...reusableBlockTools,
159
211
  ...migrationTools,
160
212
  ...formTools,
213
+ ...emailTools,
161
214
  ...settingsTools,
162
215
  ...appTools,
163
216
  ...extensionTools,
217
+ ...developerExtensionTools,
164
218
  ...searchTools,
165
219
  ...bulkTools,
166
220
  ...versionTools,
167
221
  ...deployTools,
168
222
  ...previewTools,
169
223
  ...domainTools,
224
+ ...workflowTools,
225
+ ...accessTools,
170
226
  ];
171
227
  // SDK's registerTool has deeply-nested generics we can't unify across a
172
228
  // heterogeneous tool list at compile time — same shrug as stdio's index.ts.
@@ -0,0 +1,75 @@
1
+ // Access tools — API keys, cross-organization site sharing and organization
2
+ // invites. Each wraps the authenticated public API with the same permission
3
+ // checks as the portal: a key never mints a key or a grant that reaches
4
+ // further than it does.
5
+ import { z } from 'zod';
6
+ import { ok, withErrorBoundary } from './helpers.js';
7
+ const keyId = z.string().regex(/^[a-f0-9]{12}$/).describe('Key id (the 12-hex prefix) from list_api_keys / list_organization_api_keys.');
8
+ export const accessTools = [
9
+ {
10
+ name: 'list_api_keys',
11
+ description: 'List this Site\'s site-scoped API keys: id (prefix), name, created_at/created_by, last_used_at/last_used_ip and revoked_at. Never returns secrets or hashes. Any key that reaches the Site may list, as in Site settings → API keys.',
12
+ inputSchema: {},
13
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'api-keys'))),
14
+ },
15
+ {
16
+ name: 'revoke_api_key',
17
+ description: 'Revoke one of this Site\'s API keys (admin permission on the Site). It stops authenticating immediately and stays listed as revoked for audit. Revoking the key this connection uses ends the connection. Organization keys are revoked with revoke_organization_api_key.',
18
+ inputSchema: { key_id: keyId },
19
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `api-keys/${encodeURIComponent(args.key_id)}`))),
20
+ },
21
+ {
22
+ name: 'list_site_shares',
23
+ description: 'List the organizations this Site is shared with, including revoked shares for audit: id, shared_with_org_id, permission (read | write | admin), label, created_at, revoked_at. Admin permission on the Site, as in the portal.',
24
+ inputSchema: {},
25
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'shares'))),
26
+ },
27
+ {
28
+ name: 'share_site',
29
+ description: 'Share this Site with another Organization, identified by org_id or org_slug (admin permission on the Site). permission is read, write (default) or admin; a share never exceeds admin, which the caller already holds. One active share per Organization: change it with update_site_share. Confirm the recipient with the user first — it gives every member of that Organization access.',
30
+ inputSchema: {
31
+ org_id: z.string().optional().describe('Recipient Organization id.'),
32
+ org_slug: z.string().optional().describe('Recipient Organization slug, if the id is not known.'),
33
+ permission: z.enum(['read', 'write', 'admin']).optional(),
34
+ label: z.string().max(200).optional().describe('Optional note shown in the sharing list.'),
35
+ },
36
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'shares', args))),
37
+ },
38
+ {
39
+ name: 'update_site_share',
40
+ description: 'Change the permission (read | write | admin) and/or label of an active share of this Site (admin permission on the Site). Takes effect on the recipient\'s next request.',
41
+ inputSchema: {
42
+ share_id: z.string().describe('Share id from list_site_shares.'),
43
+ permission: z.enum(['read', 'write', 'admin']).optional(),
44
+ label: z.string().max(200).optional(),
45
+ },
46
+ handler: withErrorBoundary(async ({ share_id, ...patch }, { client, siteId }) => ok(await client.patch(siteId, `shares/${encodeURIComponent(share_id)}`, patch))),
47
+ },
48
+ {
49
+ name: 'revoke_site_share',
50
+ description: 'Revoke a share of this Site (admin permission on the Site). The recipient Organization and its keys lose access immediately; the record stays listed as revoked.',
51
+ inputSchema: { share_id: z.string().describe('Share id from list_site_shares.') },
52
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.del(siteId, `shares/${encodeURIComponent(args.share_id)}`))),
53
+ },
54
+ {
55
+ name: 'list_organization_api_keys',
56
+ noSite: true,
57
+ description: 'List the Organization\'s organization-scoped API keys (metadata only, never secrets). Requires an organization API key; site-scoped keys get 403. Site-scoped keys are listed per Site with list_api_keys.',
58
+ inputSchema: {},
59
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('organization/api-keys'))),
60
+ },
61
+ {
62
+ name: 'revoke_organization_api_key',
63
+ noSite: true,
64
+ description: 'Revoke an organization-scoped API key. Requires an organization API key. It stops authenticating immediately; revoking the key this connection uses ends the connection.',
65
+ inputSchema: { key_id: keyId },
66
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootDelete(`organization/api-keys/${encodeURIComponent(args.key_id)}`))),
67
+ },
68
+ {
69
+ name: 'create_organization_invite',
70
+ noSite: true,
71
+ description: 'Create a signed invite link to the Organization (organization API key required). The invited person opens invite_url, signs in and joins as an editor; the link works until expires_at (ttl_days 1–30, default 7) and cannot be revoked early. Send it only to the person the user named.',
72
+ inputSchema: { ttl_days: z.number().int().min(1).max(30).optional() },
73
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('organization/invites', args))),
74
+ },
75
+ ];
@@ -1,16 +1,18 @@
1
- // Block-type tools. Surface is wired in now so the future block editor
2
- // drops in without breaking integrations.
1
+ // Block-type tools: discovery, authoring (validate, preview, create, update,
2
+ // delete), usage, starters and .tcblocks packages. Every write goes through
3
+ // the portal's one block type write path (shared validator, version chain,
4
+ // rename migration), and authoring needs admin permission on the Site.
3
5
  import { z } from 'zod';
4
6
  import { ok, withErrorBoundary, versionParam } from './helpers.js';
5
7
  function v(version) {
6
8
  return version ? { version } : undefined;
7
9
  }
8
- // The per-block markup. These three fields dominate the payload size (a few
9
- // dozen blocks of template HTML + scoped CSS can exceed an agent's token
10
- // budget and get truncated), and an agent enumerating the library rarely
11
- // needs them — read_block_type fetches them for one block on demand. So
12
- // list_block_types omits them by default and returns a lightweight summary.
13
- const HEAVY_BLOCK_TYPE_FIELDS = ['template', 'styles', 'script'];
10
+ // The per-block markup. These fields dominate the payload size (a few dozen
11
+ // blocks of template HTML + scoped CSS can exceed an agent's token budget and
12
+ // get truncated), and an agent enumerating the library rarely needs them —
13
+ // read_block_type fetches them for one block on demand. So list_block_types
14
+ // omits them by default and returns a lightweight summary.
15
+ const HEAVY_BLOCK_TYPE_FIELDS = ['template', 'styles', 'styles_compiled', 'script', 'composition'];
14
16
  /** Strip the heavy markup fields from each block type in a `block-types`
15
17
  * response, preserving the field `schema` and all light metadata. Defensive:
16
18
  * if the shape isn't the expected `{ block_types: [...] }`, pass it through. */
@@ -25,18 +27,47 @@ function summariseBlockTypes(res) {
25
27
  if (!bt || typeof bt !== 'object')
26
28
  return bt;
27
29
  const copy = { ...bt };
30
+ if (Array.isArray(copy.composition) && copy.composition.length)
31
+ copy.composed = true;
28
32
  for (const field of HEAVY_BLOCK_TYPE_FIELDS)
29
33
  delete copy[field];
30
34
  return copy;
31
35
  });
32
36
  return { ...envelope, block_types: slim };
33
37
  }
38
+ const AUTHORING_GUIDE = 'Two kinds of site block type. COMPOSED (preferred): `composition` is a tree of existing blocks ({ id, type, data, children? } — core/heading, core/text, core/prose, core/image, core/icon, core/button, core/container, core/grid, core/repeater …) and `schema` declares the props people edit when they place the block. Inner blocks read props through exact bindings as the WHOLE value: a heading\'s text "{{props.title}}"; a core/repeater with items "{{props.items}}" (an array prop) renders its children once per item, where "{{item.title}}" reads the item and "{{item.link.href}}" a link sub-value. Everything the inner blocks do (named styles, responsive settings, images, safe links, render versions) carries over. ' +
39
+ 'TEMPLATE (advanced, when existing blocks cannot express the markup): `template` is HTML with {{field}} (escaped), {{{field}}} (raw: richtext fields and derived …_svg / …_html only), {{#field}}…{{/field}} (shown when set), {{^field}}…{{/field}} (shown when empty), {{#each items}}…{{/each}} (per array item; names resolve against the item first; {{@index}}, {{@number}}, {{@first}}, {{@last}}), {{#link field class="…"}}…{{/link}} (wraps the body in a safe <a href rel target> when the link resolves), plus {{children}} / {{slot:NAME}} for containers. ' +
40
+ 'Fields: text, textarea, richtext, image, icon (derived {name}_svg), select (options), boolean, number, url, link ({ page_id?, url?, new_tab? } — prefer page_id for a page on the Site, it survives slug changes; resolves to .href, .target, .rel), array (a repeating group: fields, item_label, min_items, max_items; nests 3 levels), object, style … each with name, type, label and optional help, required, default. ' +
41
+ '`styles` is scoped to the block when written: every selector applies inside [data-block="<name>"], :scope is the block element itself, body/html/:root are refused; use the site\'s var(--color-*) and spacing tokens. The response returns `styles_compiled`, the CSS that ships. ' +
42
+ 'Unknown properties are errors. Every problem has a JSON-pointer `path` (and a `line` for markup/CSS); a 400 lists them all in `problems`, warnings come back in `warnings`. Call validate_block_type / preview_block_type first, and list_block_type_starters for complete examples. Needs ADMIN permission on the Site; editors (write) place block types on pages and edit their fields.';
43
+ const definitionShape = {
44
+ label: z.string().optional().describe('Display label in the block picker.'),
45
+ description: z.string().optional().describe('When to use this block type; shown to editors and agents (≤ 500 chars).'),
46
+ icon: z.string().optional().describe('Lucide icon name shown in the block picker.'),
47
+ category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
48
+ container: z.union([
49
+ z.literal(true), z.literal(false),
50
+ z.enum(['slots', 'repeater', 'conditional']),
51
+ ]).optional().describe('Template types only. true renders {{children}}, slots fixed named slots, repeater loops over items, conditional show-if. A composed type is never a container; put a container block inside its composition.'),
52
+ slot_count: z.number().optional().describe('Required when container="slots". 1-8.'),
53
+ slot_labels: z.array(z.string()).optional(),
54
+ item_compatible: z.boolean().optional().describe('Mark true if this block is designed to render as a repeater item (no outer padding).'),
55
+ expand_to: z.object({
56
+ target: z.string(),
57
+ defaults: z.record(z.unknown()),
58
+ }).optional().describe('Alias mechanism — render as another block type with these defaults merged under authored data.'),
59
+ schema: z.array(z.unknown()).optional().describe('FieldDefinition[]: { name, type, label, help?, required?, default?, options? (select), fields? (array/object), item_label?, min_items?, max_items? }. Replaces the whole schema on update.'),
60
+ composition: z.array(z.unknown()).optional().describe('Block[] a composed type is built from. Bind data to props with "{{props.name}}" and, inside a core/repeater over an array prop, to items with "{{item.name}}" — always as the whole value.'),
61
+ template: z.string().optional().describe('Markup of a template type (instead of composition); see the section syntax above.'),
62
+ styles: z.string().optional().describe('CSS scoped to the block. Write selectors for the block\'s own markup; :scope is the block element.'),
63
+ script: z.string().optional().describe("Client-side JS shipped with the block (runs on the published site's own origin). Accepted under your API key's authority — the write is audit-logged. Prefer a composed type without script."),
64
+ };
34
65
  export const blockTypeTools = [
35
66
  {
36
67
  name: 'export_block_types',
37
- 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.',
68
+ description: 'Pack site block types into a .tcblocks zip (base64). When `ids` is omitted, every block type the Site has (made in the portal, by an agent, or imported) is bundled. The package keeps every property — composition, template, styles and CSS scope, script, field help and list settings — so it moves between any Sites, in any Organization or Typeroll instance. import_block_types reads it back.',
38
69
  inputSchema: {
39
- ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every user-created block.'),
70
+ ids: z.array(z.string()).optional().describe('Specific block-type ids to include. Omit to export every site block type.'),
40
71
  name: z.string().optional().describe('Package name (default: {site}-blocks).'),
41
72
  version: z.string().optional().describe('Package version written into the manifest (default: 1.0.0). This is the package semver, not a Site version — use version_branch for that.'),
42
73
  version_branch: versionParam,
@@ -60,26 +91,29 @@ export const blockTypeTools = [
60
91
  },
61
92
  {
62
93
  name: 'import_block_types',
63
- 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.',
94
+ description: 'Install block types from a .tcblocks package (zip as base64). Needs ADMIN permission on the Site. Each definition is checked by the same validator as create_block_type; a definition with errors is reported as failed and not written. On a name conflict `on_conflict` decides: skip (default) keeps the Site\'s type, rename imports it under a free name (references inside the package follow), replace overwrites it. Returns `results` per type (created, renamed, replaced, skipped, failed with problems) and counts. Imported types may carry CSS and JS — only import packages you trust.',
64
95
  inputSchema: {
65
96
  zip_base64: z.string().describe('Base64-encoded .tcblocks zip.'),
97
+ on_conflict: z.enum(['skip', 'rename', 'replace']).optional().describe('What to do when the Site already has a block type with the same name. Default skip.'),
66
98
  version: versionParam,
67
99
  },
68
100
  handler: withErrorBoundary(async (args, { client, siteId }) => {
69
- const v = args.version ? { version: args.version } : undefined;
70
- const res = await client.post(siteId, 'blocks/import', { zip_base64: args.zip_base64 }, v);
101
+ const res = await client.post(siteId, 'blocks/import', {
102
+ zip_base64: args.zip_base64,
103
+ ...(args.on_conflict ? { on_conflict: args.on_conflict } : {}),
104
+ }, v(args.version));
71
105
  return ok(res);
72
106
  }),
73
107
  },
74
108
  {
75
109
  name: 'list_block_types',
76
- 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). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site. By default this is a LIGHTWEIGHT SUMMARY: each entry has id, label, category, icon, container/slot info, origin, and the full field schema (name/type/label/required/options/default) — everything you need to pick a block and call add_block — but NOT the render-time markup. The per-block template HTML, scoped styles, and script are omitted so the list stays within token budget no matter how large the library grows; fetch them for a single block with read_block_type, or pass full:true to inline them for every block (can be very large).',
110
+ description: 'Discover every block type usable on this site. Returns ALL of them in one list: core blocks (id like "core/section", always available), site block types (origin "user" from the portal, "ai" from an agent) and third-party blocks (origin "third_party", imported from .tcblocks packages). Call this FIRST when starting block-mode work — never hardcode block ids; the available set is per-site. By default this is a LIGHTWEIGHT SUMMARY: id, label, description, category, icon, container/slot info, origin, `composed: true` for composed types, and the full field schema — everything you need to pick a block and call add_block — but NOT the template, composition, styles or script. Fetch those for one block with read_block_type, or pass full:true to inline them for every block (can be very large).',
77
111
  inputSchema: {
78
- include_core: z.boolean().optional().describe('Default true. Set false to get only custom + third-party (rarely needed).'),
112
+ include_core: z.boolean().optional().describe('Default true. Set false to get only the site\'s own and third-party types.'),
79
113
  full: z
80
114
  .boolean()
81
115
  .optional()
82
- .describe("Default false. When true, include each block type's template HTML, styles CSS, and script inline — the full doc. Large; prefer read_block_type for one block's markup."),
116
+ .describe("Default false. When true, include each block type's template, composition, styles and script inline — the full doc. Large; prefer read_block_type for one block."),
83
117
  version: versionParam,
84
118
  },
85
119
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -94,7 +128,7 @@ export const blockTypeTools = [
94
128
  },
95
129
  {
96
130
  name: 'read_block_type',
97
- 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.",
131
+ description: "Full definition of one block type: the field schema, and its composition (composed types) or template (template types), styles with `styles_compiled` (the scoped CSS that ships), and optional script. Use this when list_block_types' summary isn't enough — before add_block on an unfamiliar block, or before update_block_type.",
98
132
  inputSchema: {
99
133
  type_id: z.string(),
100
134
  version: versionParam,
@@ -106,7 +140,7 @@ export const blockTypeTools = [
106
140
  },
107
141
  {
108
142
  name: 'find_pages_using_block_type',
109
- description: 'Pages whose block tree contains this block type. Empty for HTML-mode sites (the default today); fills in once block-mode pages exist.',
143
+ description: 'Everything that uses a block type, on every surface: `pages` (saved block pages and drafts — `sources` says which), `templates` (page templates), `partials` (header, footer, global blocks), `block_templates`, and `block_types` (other site block types built from it). Repeaters that render the type as their item_block count, and a use through another block type names it in `via`. `total` sums the lists; delete_block_type is refused while it is above zero. Call it before changing or removing fields of a type in use.',
110
144
  inputSchema: {
111
145
  type_id: z.string(),
112
146
  version: versionParam,
@@ -116,36 +150,62 @@ export const blockTypeTools = [
116
150
  return ok(res);
117
151
  }),
118
152
  },
153
+ {
154
+ name: 'list_block_type_starters',
155
+ description: 'Built-in starting points for a new block type: complete composed definitions (icon list, feature cards, numbered steps) with fields, composition and scoped CSS. Adapt one (rename it, change fields) and pass it to create_block_type — it is the quickest way to a valid composed type.',
156
+ inputSchema: {},
157
+ handler: withErrorBoundary(async (_args, { client, siteId }) => {
158
+ const res = await client.get(siteId, 'block-types/starters');
159
+ return ok(res);
160
+ }),
161
+ },
162
+ {
163
+ name: 'validate_block_type',
164
+ description: `Check a block type definition without saving it, with the exact validator create_block_type and update_block_type use. Pass the whole definition, or with type_id a patch to that stored type (renames are checked too). Returns { ok, problems, merged }: every error and warning with a JSON-pointer path (e.g. /schema/0/fields/2/name, /composition/0/children/1/data/text) and a line for markup and CSS, and the definition as it would be stored. Writes nothing. ${AUTHORING_GUIDE}`,
165
+ inputSchema: {
166
+ definition: z.record(z.unknown()).describe('The block type definition (as for create_block_type), or a patch when type_id is set.'),
167
+ type_id: z.string().optional().describe('Validate `definition` as a patch to this stored block type.'),
168
+ version: versionParam,
169
+ },
170
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
171
+ const query = {};
172
+ if (args.type_id)
173
+ query.type_id = args.type_id;
174
+ if (args.version)
175
+ query.version = args.version;
176
+ const res = await client.post(siteId, 'block-types/validate', args.definition, query);
177
+ return ok(res);
178
+ }),
179
+ },
180
+ {
181
+ name: 'preview_block_type',
182
+ description: 'Render a block type definition that need not be saved, through the same preview renderer as the portal (the Site\'s render version, theme CSS, named styles, custom CSS, the other site block types, and the sanitizer). Without `data` the block shows sample values derived from its fields. Returns { ok, html, css, document, data, problems }: `html` is the block\'s markup as a page carries it, `css` the scoped block CSS that ships, `document` a standalone HTML page. A definition with errors is not rendered (ok:false, problems listed). Block scripts never run in a preview. Writes nothing.',
183
+ inputSchema: {
184
+ definition: z.record(z.unknown()).optional().describe('The block type definition, or a patch when type_id is set. Omit with type_id to preview the stored type.'),
185
+ type_id: z.string().optional().describe('Preview `definition` as a patch to this stored block type.'),
186
+ data: z.record(z.unknown()).optional().describe('Field values for the previewed block. Omit for sample data.'),
187
+ render_version: z.number().int().optional().describe('Render with this platform render version instead of the Site\'s own.'),
188
+ version: versionParam,
189
+ },
190
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
191
+ const { version, ...body } = args;
192
+ const res = await client.post(siteId, 'block-types/preview', { ...body, definition: body.definition ?? {} }, v(version));
193
+ return ok(res);
194
+ }),
195
+ },
119
196
  // ─── BlockType authoring — CREATE / UPDATE / DELETE ──────────────────
120
- // `script` runs with full DOM access in every visitor's browser. Through
121
- // an API key it is ACCEPTED under the key holder's authority (same trust
122
- // level as scripts_head) — the write is audit-logged and the response
123
- // carries a notice naming the stored JS. Only the in-portal chat AI
124
- // remains gated on Site.ai_scripts_enabled. Origin is stamped 'ai' so
125
- // audit + UI distinguish agent-authored types.
197
+ // Admin permission on the Site, as in the portal. `script` runs with full
198
+ // DOM access in every visitor's browser. Through an API key it is ACCEPTED
199
+ // under the key holder's authority (same trust level as scripts_head) and
200
+ // the write is audit-logged. Only the in-portal chat never writes it.
201
+ // Origin is stamped 'ai' so audit + UI distinguish agent-authored types.
126
202
  {
127
203
  name: 'create_block_type',
128
- description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. This is the supported way to ship per-page JS: `<script>` written into page or block MARKUP is stripped by the renderer's sanitizer no matter which credential wrote it, so package the behaviour as a block type instead. Site-wide tags belong in settings.scripts_head / scripts_body_end. Returns the created BlockType. Responsive fields: mark a schema field `responsive: true` and expose it as style=\"--{field}:{{field}}\" on the outermost element so it can vary per breakpoint. If the value is directly usable CSS, read var(--{field}); if it's a token that maps to CSS (e.g. layout 'icon-left' → flex-direction:row) add a `responsive_css` map on the field ({ 'icon-left': '--dir: row;' }), otherwise per-breakpoint overrides won't take effect.",
204
+ description: `Create a block type for this Site when no core block (or block template) fits a recurring shape. Origin is stamped 'ai'; id = name. The type is available to every page editor immediately and reaches the live site with the next deploy. ${AUTHORING_GUIDE} Returns { block_type, warnings }; 409 when the name exists. A block type is the supported way to ship per-block JS (\`script\`): <script> written into page or block markup is always stripped by the sanitizer. Site-wide tags belong in settings.scripts_head / scripts_body_end.`,
129
205
  inputSchema: {
130
- name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
131
- label: z.string().optional().describe('Display label (defaults to name).'),
132
- icon: z.string().optional().describe('Lucide icon name shown in the block picker.'),
133
- category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
134
- container: z.union([
135
- z.literal(true), z.literal(false),
136
- z.enum(['slots', 'repeater', 'conditional']),
137
- ]).optional().describe('Container kind. Use slots for fixed named slots, repeater for looping over items, conditional for show-if blocks.'),
138
- slot_count: z.number().optional().describe('Required when container="slots". 1-8.'),
139
- slot_labels: z.array(z.string()).optional(),
140
- item_compatible: z.boolean().optional().describe('Mark true if this block is designed to render as a repeater item (no outer padding, kernel layout).'),
141
- expand_to: z.object({
142
- target: z.string(),
143
- defaults: z.record(z.unknown()),
144
- }).optional().describe('Alias mechanism — render as another block type with these defaults merged under authored data.'),
145
- schema: z.array(z.unknown()).optional().describe('FieldDefinition[]. Each field has name, type, label, optional required/default/options/responsive.'),
146
- template: z.string().optional().describe('HTML with {{field}}, {{{field}}}, {{=tag}}, {{children}}, {{slot:NAME}} substitutions.'),
147
- styles: z.string().optional().describe('Block-scoped CSS. Selectors should start with [data-block="<name>"].'),
148
- script: z.string().optional().describe("Client-side JS shipped with the block (runs on the published site). Accepted under your API key's authority — the write is audit-logged and the response carries a notice naming the stored JS. It runs on the published site's own origin, which shares nothing with the Typeroll portal."),
206
+ name: z.string().describe('Machine name (lowercase letters, digits, "-" and "_", 1-64 chars). Becomes the id.'),
207
+ ...definitionShape,
208
+ label: z.string().describe('Display label in the block picker.'),
149
209
  version: versionParam,
150
210
  },
151
211
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -160,24 +220,12 @@ export const blockTypeTools = [
160
220
  },
161
221
  {
162
222
  name: 'update_block_type',
163
- description: "Update fields of an existing custom block type. Core blocks (id starts with 'core/') are read-only — they're managed in platform code. Schema/template/styles replace wholesale (no deep merge); other fields shallow-merge. The `script` field follows the same policy as create_block_type — accepted under your key's authority, audit-logged, notice in the response.",
223
+ description: "Change a site block type (core/* types are read-only). Needs ADMIN permission. schema, composition, template and styles replace wholesale when sent; other properties merge. Changing a type in use: to RENAME a field, send the new schema with `renames` ({ \"title\": \"heading\", \"items.label\": \"text\" } — dotted paths of the OLD names, new name of the field) and the data moves in every block that uses the type (saved pages and drafts, page templates, header/footer/global blocks, block templates, other block types, repeater items), after a revision of each changed page; a composed type's own {{props.…}}/{{item.…}} bindings follow unless you send a composition. Removing or retyping a field that holds data answers 409 with the affected uses until you resend with confirm_data_loss: true (ask the user first). Returns { block_type, warnings, impact: { usages, renamed, removed, retyped } }. The same validation as create_block_type applies.",
164
224
  inputSchema: {
165
- type_id: z.string().describe('Block type id (custom or third_party). Cannot be a core/* block.'),
166
- label: z.string().optional(),
167
- icon: z.string().optional(),
168
- category: z.enum(['layout', 'content', 'media', 'custom']).optional(),
169
- container: z.union([
170
- z.literal(true), z.literal(false),
171
- z.enum(['slots', 'repeater', 'conditional']),
172
- ]).optional(),
173
- slot_count: z.number().optional(),
174
- slot_labels: z.array(z.string()).optional(),
175
- item_compatible: z.boolean().optional(),
176
- expand_to: z.object({ target: z.string(), defaults: z.record(z.unknown()) }).optional(),
177
- schema: z.array(z.unknown()).optional(),
178
- template: z.string().optional(),
179
- styles: z.string().optional(),
180
- script: z.string().optional().describe("Accepted under your API key's authority; audit-logged, and the response carries a notice naming the stored JS."),
225
+ type_id: z.string().describe('Block type id (a site type). Cannot be a core/* block.'),
226
+ ...definitionShape,
227
+ renames: z.record(z.string()).optional().describe('Old field path → new field name, e.g. { "title": "heading", "items.label": "text" }. Send together with the new schema.'),
228
+ confirm_data_loss: z.boolean().optional().describe('Drop the data of removed or retyped fields that hold data. Only after the user agreed; the 409 lists what would be lost.'),
181
229
  version: versionParam,
182
230
  },
183
231
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -188,7 +236,7 @@ export const blockTypeTools = [
188
236
  },
189
237
  {
190
238
  name: 'delete_block_type',
191
- description: "Remove a custom block type. Core blocks (core/*) cannot be deleted. **Warning**: pages currently using this block type will render '<!-- unknown block type -->' comments instead. Call find_pages_using_block_type FIRST to see the blast radius; the AI should prompt the user before deleting if usage > 0.",
239
+ description: "Remove a site block type. Needs ADMIN permission; core blocks (core/*) cannot be deleted. Refused with 409 while anything uses the type — pages and drafts, page templates, header, footer, global blocks, block templates, other block types or repeaters — and the refusal lists the uses (as find_pages_using_block_type does). Remove those uses first and confirm with the user.",
192
240
  inputSchema: {
193
241
  type_id: z.string(),
194
242
  version: versionParam,
@@ -28,6 +28,39 @@ export const domainTools = [
28
28
  inputSchema: { revision: z.string() },
29
29
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'publishing/managed-migration', args))),
30
30
  },
31
+ {
32
+ name: 'read_organization_publishing_connections', noSite: true,
33
+ description: 'Read the Organization\'s GitHub and Cloudflare publishing connections: status, revision (needed to change or disconnect), account identity, media_ready, media migration, and connect_urls. Requires an organization API key; never returns credentials. GitHub sign-in/App installation and Cloudflare OAuth need a person in a browser: give the user the matching connect_urls link (an organization owner or admin completes it), then read again.',
34
+ inputSchema: {},
35
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('publishing/connections'))),
36
+ },
37
+ {
38
+ name: 'disconnect_organization_publishing_provider', noSite: true,
39
+ description: 'Disconnect the Organization\'s GitHub or Cloudflare publishing connection at the revision from read_organization_publishing_connections. Erases stored credentials; does not delete repositories, buckets, media, DNS or published sites, but publishing through that provider stops until reconnected. Requires an organization API key and the user\'s explicit go-ahead.',
40
+ inputSchema: { provider: z.enum(['github', 'cloudflare']), revision: z.string(), hosting_group_id: z.string().optional().describe('Omit for the organization (Default) connection.') },
41
+ handler: withErrorBoundary(async ({ provider, ...body }, { client }) => ok(await client.rootDelete(`publishing/connections/${provider}`, body))),
42
+ },
43
+ {
44
+ name: 'connect_organization_cloudflare', noSite: true,
45
+ description: 'Connect the Organization\'s default Cloudflare account without a browser, using a customer Cloudflare API token plus R2 access keys for an existing private bucket. Verifies the account, Pages access and the bucket before saving; credentials are encrypted and never returned. Requires an organization API key and the current revision. Reconnecting must keep the same account and bucket. For additional Hosting Groups use connect_hosting_group.',
46
+ inputSchema: {
47
+ revision: z.string(), account_id: z.string().regex(/^[a-f0-9]{32}$/), bucket: z.string(),
48
+ api_token: z.string(), access_key_id: z.string(), secret_access_key: z.string(),
49
+ },
50
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('publishing/connections/cloudflare', { ...args, action: 'connect' }))),
51
+ },
52
+ {
53
+ name: 'prepare_organization_media_storage', noSite: true,
54
+ description: 'Create or reuse the Organization\'s private and public R2 media buckets in the connected Cloudflare account and configure upload CORS and lifecycle rules. Run after Cloudflare is connected and before save_organization_media_access. Requires an organization API key and the current Cloudflare revision. Idempotent.',
55
+ inputSchema: { revision: z.string() },
56
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('publishing/connections/cloudflare', { ...args, action: 'prepare_media' }))),
57
+ },
58
+ {
59
+ name: 'save_organization_media_access', noSite: true,
60
+ description: 'Save and verify the Cloudflare R2 Access Key ID and Secret Access Key for the prepared media buckets (not the Cloudflare API token). Marks media storage ready, which imports require, and queues media migration. Requires an organization API key and the current Cloudflare revision. Keys are encrypted and never returned.',
61
+ inputSchema: { revision: z.string(), access_key_id: z.string(), secret_access_key: z.string() },
62
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('publishing/connections/cloudflare', { ...args, action: 'save_media' }))),
63
+ },
31
64
  {
32
65
  name: 'check_organization_github_permissions', noSite: true,
33
66
  description: 'Check live GitHub build permissions without disconnecting or reconnecting. Requires an organization API key. Distinguishes an update the organization owner can approve from permissions Typeroll has not requested yet. Returns the existing installation approval URL only when actionable; does not enable a build engine.',
@@ -0,0 +1,69 @@
1
+ // Site email — the same settings as the portal's Settings → Email & notifications, with the
2
+ // same admin permission: the outgoing connector that form notifications send
3
+ // through (with a test send), and incoming email forwarding for host-approved
4
+ // addresses. Secrets are write-only: reads return { set: true } instead.
5
+ import { z } from 'zod';
6
+ import { ok, withErrorBoundary } from './helpers.js';
7
+ export const emailTools = [
8
+ {
9
+ name: 'get_email_settings',
10
+ description: "Read the site's outgoing email connector (the provider form notifications send through): type, from, reply_to and config with secrets masked as { set: true }, plus every available provider with its field schema and whether the server can store secrets (crypto_configured). email is null when nothing is connected. Admin permission required.",
11
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'integrations/email'))),
12
+ },
13
+ {
14
+ name: 'set_email_settings',
15
+ description: "Connect or change the site's outgoing email provider, like Settings → Email & notifications in the portal. Read get_email_settings first for the provider field schema. Examples: postmark config { server_token, message_stream? }; smtp config { host, port?, secure?, user?, password? }. Secrets are encrypted server-side and never returned; omit a secret (or send the masked value) to keep the stored one. Then call send_test_email. Admin permission required.",
16
+ inputSchema: {
17
+ type: z.string().min(1).describe('Provider type from get_email_settings providers, e.g. "postmark" or "smtp".'),
18
+ from: z.string().min(1).describe('Sender, e.g. "Acme <hello@acme.com>". Must be a sender the provider accepts.'),
19
+ reply_to: z.string().optional().describe('Optional default Reply-To address.'),
20
+ config: z.record(z.unknown()).optional().describe('Provider fields keyed by the field schema.'),
21
+ },
22
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.put(siteId, 'integrations/email', {
23
+ type: args.type,
24
+ from: args.from,
25
+ ...(args.reply_to !== undefined ? { reply_to: args.reply_to } : {}),
26
+ config: args.config ?? {},
27
+ }))),
28
+ },
29
+ {
30
+ name: 'delete_email_settings',
31
+ description: "Disconnect the site's outgoing email provider and delete its stored credentials. Form email notifications stop being sent until a provider is connected again; submissions are still stored. Admin permission required.",
32
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.del(siteId, 'integrations/email'))),
33
+ },
34
+ {
35
+ name: 'send_test_email',
36
+ description: 'Send a test email through the stored email connector to check it works, like "Send test" in the portal. Save the connector with set_email_settings first. Admin permission required.',
37
+ inputSchema: {
38
+ to: z.string().min(3).describe('Recipient address for the test message.'),
39
+ },
40
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'integrations/email/test', { to: args.to }))),
41
+ },
42
+ {
43
+ name: 'get_incoming_email_settings',
44
+ description: "Read the site's incoming email forwarding: host-approved routes (alias → forwarding target), each route's current revision, whether forwarding is enabled and the last message receipt with delivery status. Routes are created by the hosting administrator; an empty list means none is approved yet. Admin permission required.",
45
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'delivery/inbound'))),
46
+ },
47
+ {
48
+ name: 'set_incoming_email_forwarding',
49
+ description: 'Enable or disable forwarding for one host-approved incoming email route, like Settings → Email & notifications in the portal. Pass the route_id and the current revision from get_incoming_email_settings; a changed route (409) must be read again and reviewed first. This cannot create aliases or change targets. Enable, verify with a real test message, and only then change the site\'s Reply-To. Admin permission required.',
50
+ inputSchema: {
51
+ route_id: z.string().min(1),
52
+ revision: z.string().min(1).describe('The revision returned by get_incoming_email_settings for this route.'),
53
+ enabled: z.boolean(),
54
+ },
55
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.put(siteId, 'delivery/inbound', {
56
+ route_id: args.route_id,
57
+ revision: args.revision,
58
+ enabled: args.enabled,
59
+ }))),
60
+ },
61
+ {
62
+ name: 'read_incoming_email_receipt',
63
+ description: 'Read one incoming email receipt (status such as forwarded, held or blocked, its reason and the forwarded delivery status) by the receipt_id shown in get_incoming_email_settings. Contains no subject, sender or body. Admin permission required.',
64
+ inputSchema: {
65
+ receipt_id: z.string().regex(/^[a-f0-9]{64}$/),
66
+ },
67
+ handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `delivery/inbound/${encodeURIComponent(args.receipt_id)}`))),
68
+ },
69
+ ];