@typeroll/mcp-server 0.45.44 → 0.45.45

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 or email 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';
@@ -60,8 +63,48 @@ 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')
64
105
  return 'admin';
106
+ if (name === 'diff_version')
107
+ return 'read';
65
108
  if (name.startsWith('list_') ||
66
109
  name.startsWith('read_') ||
67
110
  name.startsWith('get_') ||
@@ -158,15 +201,19 @@ export function buildServer(options) {
158
201
  ...reusableBlockTools,
159
202
  ...migrationTools,
160
203
  ...formTools,
204
+ ...emailTools,
161
205
  ...settingsTools,
162
206
  ...appTools,
163
207
  ...extensionTools,
208
+ ...developerExtensionTools,
164
209
  ...searchTools,
165
210
  ...bulkTools,
166
211
  ...versionTools,
167
212
  ...deployTools,
168
213
  ...previewTools,
169
214
  ...domainTools,
215
+ ...workflowTools,
216
+ ...accessTools,
170
217
  ];
171
218
  // SDK's registerTool has deeply-nested generics we can't unify across a
172
219
  // 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
+ ];
@@ -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
+ ];
@@ -66,4 +66,135 @@ export const extensionTools = [
66
66
  }
67
67
  }),
68
68
  },
69
+ {
70
+ name: 'install_extension',
71
+ description: 'Install an Extension release on this site, as the portal\'s install action does. Pass the Extension id and a published release (developer organizations may also install their own draft or review releases on their own sites). granted_scopes must be a subset of the manifest\'s requested permissions — grant only what the user approved. config must satisfy manifest.config_schema; secret values are encrypted and never returned. Does not deploy: trigger_deploy afterwards to publish provisioned components. Admin permission required.',
72
+ inputSchema: {
73
+ extension_id: z.string().min(1),
74
+ version: z.string().min(1).describe('Release version to install.'),
75
+ granted_scopes: z.array(z.string()).optional().describe('Approved scopes; defaults to none.'),
76
+ config: z.record(z.unknown()).optional(),
77
+ developer_org_id: z.string().optional().describe('Organization that publishes the Extension. Defaults to the key\'s own organization.'),
78
+ },
79
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
80
+ return ok(await client.post(siteId, 'extensions', args));
81
+ }),
82
+ },
83
+ {
84
+ name: 'set_extension_installation_status',
85
+ description: 'Enable or disable an installed Extension on this site. Disabling removes its components from the public snapshot and editor until re-enabled; page instances are kept. Changes apply immediately to previews; deploy to publish. Admin permission required.',
86
+ inputSchema: {
87
+ installation_id: z.string().min(1),
88
+ status: z.enum(['enabled', 'disabled']),
89
+ },
90
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
91
+ return ok(await client.patch(siteId, `extensions/${encodeURIComponent(args.installation_id)}`, { status: args.status }));
92
+ }),
93
+ },
94
+ {
95
+ name: 'uninstall_extension',
96
+ description: 'Uninstall an Extension from this site: revokes the installation and all its server credentials and removes its block definitions. Existing page blocks remain as unavailable placeholders. Not reversible except by installing again (a new installation id). Get explicit user approval. Admin permission required.',
97
+ inputSchema: { installation_id: z.string().min(1) },
98
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
99
+ return ok(await client.del(siteId, `extensions/${encodeURIComponent(args.installation_id)}`));
100
+ }),
101
+ },
102
+ {
103
+ name: 'pair_extension_issuer',
104
+ description: 'Pair this Typeroll instance\'s token issuer with the Extension provider (manifest auth.pairing_url), the portal\'s "secure connection" action. Contacts the provider. Returns the trusted issuer record. Admin permission required.',
105
+ inputSchema: { installation_id: z.string().min(1) },
106
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
107
+ return ok(await client.post(siteId, `extensions/${encodeURIComponent(args.installation_id)}/pair`));
108
+ }),
109
+ },
110
+ {
111
+ name: 'read_extension_diagnostics',
112
+ description: 'Read an installation\'s diagnostics: status, health, release resolution, credential metadata (no secrets), the latest audit events and lifecycle event deliveries, and declared URL context inputs. Admin permission required.',
113
+ inputSchema: { installation_id: z.string().min(1) },
114
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
115
+ return ok(await client.get(siteId, `extensions/${encodeURIComponent(args.installation_id)}/diagnostics`));
116
+ }),
117
+ },
118
+ {
119
+ name: 'launch_extension_admin_page',
120
+ description: 'Issue a single-use, short-lived launch grant for one of the Extension\'s admin pages (manifest admin.pages), as opening the page in the portal does. Returns { launch_url, method, form, expires_at }: a browser tool must POST the `form` fields to launch_url before expires_at. The provider sees the caller as api-key:{prefix}. Treat the code as a credential. For approved native pages prefer call_extension_admin. The page\'s minimum_permission applies.',
121
+ inputSchema: {
122
+ installation_id: z.string().min(1),
123
+ page_id: z.string().min(1).describe('Admin page id from the installation manifest.'),
124
+ },
125
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
126
+ return ok(await client.post(siteId, `extensions/${encodeURIComponent(args.installation_id)}/launch`, { page_id: args.page_id }));
127
+ }),
128
+ },
129
+ ];
130
+ const ext = (id) => `extensions/${encodeURIComponent(id)}`;
131
+ /** Extension developer tools — the `typeroll extension` CLI's API. They act on
132
+ * the key's own organization, not on a site, and need an
133
+ * organization-scoped key (a site key gets 403). */
134
+ export const developerExtensionTools = [
135
+ {
136
+ name: 'list_developer_extensions', noSite: true,
137
+ description: 'List Extensions registered by your organization (developer side). Requires an organization API key.',
138
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.developer('GET', 'extensions'))),
139
+ },
140
+ {
141
+ name: 'read_developer_extension', noSite: true,
142
+ description: 'Read one of your organization\'s Extensions with all saved releases and their status (draft, review, published, deprecated, revoked). Requires an organization API key.',
143
+ inputSchema: { extension_id: z.string().min(1) },
144
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.developer('GET', ext(args.extension_id)))),
145
+ },
146
+ {
147
+ name: 'update_developer_extension', noSite: true,
148
+ description: 'Update an Extension\'s name, status (active/suspended), distribution (only while every release is a draft), trusted origins or private allow-lists. Requires an organization API key.',
149
+ inputSchema: {
150
+ extension_id: z.string().min(1),
151
+ name: z.string().optional(),
152
+ status: z.enum(['active', 'suspended']).optional(),
153
+ distribution: z.enum(['private', 'unlisted', 'public']).optional(),
154
+ trusted_origins: z.array(z.string()).optional(),
155
+ allowed_org_ids: z.array(z.string()).optional(),
156
+ allowed_site_ids: z.array(z.string()).optional(),
157
+ },
158
+ handler: withErrorBoundary(async (args, { client }) => {
159
+ const { extension_id, ...patch } = args;
160
+ return ok(await client.developer('PATCH', ext(extension_id), patch));
161
+ }),
162
+ },
163
+ {
164
+ name: 'save_extension_version', noSite: true,
165
+ description: 'Save (create or replace) a draft release from a full Extension manifest. Published releases are immutable; bump manifest.version instead. Validate locally with `typeroll extension validate` first. Requires an organization API key.',
166
+ inputSchema: {
167
+ extension_id: z.string().min(1),
168
+ manifest: z.record(z.unknown()).describe('Complete manifest; its id must equal extension_id.'),
169
+ },
170
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.developer('POST', `${ext(args.extension_id)}/versions`, { manifest: args.manifest }))),
171
+ },
172
+ {
173
+ name: 'publish_extension_version', noSite: true,
174
+ description: 'Publish a saved release after verifying its asset digests. Private releases become installable for allowed organizations and sites; public releases enter review before catalog discovery. Installations follow compatible published releases automatically, so get user approval. Requires an organization API key.',
175
+ inputSchema: { extension_id: z.string().min(1), version: z.string().min(1) },
176
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.developer('POST', `${ext(args.extension_id)}/versions/${encodeURIComponent(args.version)}/publish`))),
177
+ },
178
+ {
179
+ name: 'set_extension_version_lifecycle', noSite: true,
180
+ description: 'Deprecate or revoke a release. Revoking stops installations from resolving to it. Requires an organization API key.',
181
+ inputSchema: {
182
+ extension_id: z.string().min(1),
183
+ version: z.string().min(1),
184
+ status: z.enum(['deprecated', 'revoked']),
185
+ reason: z.string().optional(),
186
+ },
187
+ handler: withErrorBoundary(async (args, { client }) => {
188
+ const { extension_id, version, ...body } = args;
189
+ return ok(await client.developer('PATCH', `${ext(extension_id)}/versions/${encodeURIComponent(version)}`, body));
190
+ }),
191
+ },
192
+ {
193
+ name: 'list_developer_extension_installations', noSite: true,
194
+ description: 'List operational metadata (status, versions, health; never config) for your Extension\'s installations on one customer site. Requires an organization API key and the customer\'s organization and site ids.',
195
+ inputSchema: { extension_id: z.string().min(1), owner_org_id: z.string().min(1), site_id: z.string().min(1) },
196
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.developer('GET', `${ext(args.extension_id)}/installations`, undefined, {
197
+ owner_org_id: args.owner_org_id, site_id: args.site_id,
198
+ }))),
199
+ },
69
200
  ];
@@ -39,11 +39,11 @@ const targetSchema = z.object({
39
39
  // Forms editor; admin permission required, as in the portal.
40
40
  const actionSchema = z.object({
41
41
  id: z.string().optional().describe('Keep an existing action\'s id to update it in place (webhooks keep their stored secret).'),
42
- type: z.string().describe('"email", "webhook", or an action type provided by an installed app.'),
42
+ type: z.string().describe('"email", "webhook", or an action type provided by an installed app (see get_form_capabilities).'),
43
43
  config: z.record(z.string(), z.unknown()).describe('email: { to, subject, body, cc?, bcc?, reply_to?, include_all?, format?: "html"|"text" } — to/subject/body may use {{field}} placeholders, e.g. to: "{{email}}" for a confirmation to the visitor. ' +
44
44
  'webhook: { url (https), fields: ["name","email"] (only these values are sent), secret (signing secret; send "••••••••" to keep the stored one) }.'),
45
45
  });
46
- const actionsDescription = 'The complete list of actions after a submission (replaces the current list): email notifications, webhooks, app actions. Read the form first and send back the actions you keep. Requires admin permission, like the portal.';
46
+ const actionsDescription = 'The complete list of actions after a submission (replaces the current list): email notifications, webhooks, app actions (get_form_capabilities lists the types and their config). Read the form first and send back the actions you keep. Emails send through the provider set with set_email_settings. Requires admin permission, like the portal.';
47
47
  export const formTools = [
48
48
  {
49
49
  name: 'list_forms',
@@ -53,6 +53,11 @@ export const formTools = [
53
53
  return ok(res);
54
54
  }),
55
55
  },
56
+ {
57
+ name: 'get_form_capabilities',
58
+ description: 'List the action types a form can run after a submission (core "email" and "webhook" plus types provided by installed apps) and the prefill sources, each with its config field schema — the same choices as the portal\'s Forms editor. Read this before writing `actions` with create_form/update_form. Admin permission required.',
59
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'form-capabilities'))),
60
+ },
56
61
  {
57
62
  name: 'read_form',
58
63
  description: 'Read one form by id, including fields/steps, submit_text, success_message and, for admins, its actions (email notifications and webhooks; secrets masked). Place it with a `core/form` block using data.form_id on a block-mode page, or `<x-form id="…" />` in HTML mode. Both references are expanded server-side with validation, signed token, initial state, and the shared runtime.',
@@ -119,7 +124,7 @@ export const formTools = [
119
124
  },
120
125
  {
121
126
  name: 'list_form_submissions',
122
- 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.',
127
+ description: 'List submissions received for a form, newest first, cursor-paginated (cap 200 per page). Admins also see each submission\'s webhook delivery status. Use this to help a customer triage inbound contact / lead submissions; read_form_submission reads one.',
123
128
  inputSchema: {
124
129
  form_id: z.string(),
125
130
  limit: z.number().int().min(1).max(200).optional(),
@@ -131,6 +136,18 @@ export const formTools = [
131
136
  return ok(res);
132
137
  }),
133
138
  },
139
+ {
140
+ name: 'read_form_submission',
141
+ description: 'Read one submission of a form by id (from list_form_submissions): the submitted values, status (partial or complete), step and timestamps. Admins also see its webhook delivery status, as in the portal.',
142
+ inputSchema: {
143
+ form_id: z.string(),
144
+ submission_id: z.string(),
145
+ },
146
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
147
+ const res = await client.get(siteId, `forms/${encodeURIComponent(args.form_id)}/submissions/${encodeURIComponent(args.submission_id)}`);
148
+ return ok(res);
149
+ }),
150
+ },
134
151
  {
135
152
  name: 'delete_form_submission',
136
153
  description: 'Delete a single submission from a form\'s inbox. Use this to remove individual entries (test submissions, spam) without touching the rest — delete_form with delete_submissions is the only way to bulk-delete. Get submission ids from list_form_submissions.',
@@ -18,6 +18,12 @@ export const mediaTools = [
18
18
  inputSchema: {},
19
19
  handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'media/import'))),
20
20
  },
21
+ {
22
+ name: 'get_media_upload_status',
23
+ description: 'Pre-flight for uploads: whether media uploads can go through for this site right now (organization storage connected, or platform storage configured), with the reason and a settings_url when they cannot. Same check as the banner in the portal media library. Requires site read access.',
24
+ inputSchema: {},
25
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'media/upload-status'))),
26
+ },
21
27
  {
22
28
  name: 'read_private_media_url',
23
29
  description: 'Get a 60-second read URL for a private media original. Use it for authorized image inspection; never store it in page content or generated source. Use the stable media URL or ID in content.',
@@ -229,6 +235,12 @@ export const mediaTools = [
229
235
  return ok(res);
230
236
  }),
231
237
  },
238
+ {
239
+ name: 'purge_site_media',
240
+ description: 'Permanently delete every media object and record of an ARCHIVED site (archive_site first; an active site returns 409). Irreversible — the step before retiring a site for good, same as the portal action. Requires an admin key of the organization that owns the site; shared access is refused. Returns counts; HTTP 207 with `failed` lists objects that could not be removed (their records are kept) — call again to retry exactly those. Confirm with the user before calling.',
241
+ inputSchema: {},
242
+ handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.post(siteId, 'media/purge'))),
243
+ },
232
244
  {
233
245
  name: 'delete_media',
234
246
  description: 'Delete a media item\'s metadata. (R2 object cleanup is a separate ops task.)',
@@ -287,7 +287,7 @@ export const pageTools = [
287
287
  // the block-tree mutation tools (add/update/move/remove/convert).
288
288
  {
289
289
  name: 'get_page_preview',
290
- description: "Get the WHOLE page rendered as one self-contained HTML document — header partial + block-rendered body + footer partial, with the site's settings CSS variables, global styles, and the tree-shaken block-CSS bundle all inlined, exactly as deployed. This is the single artifact for UNDERSTANDING what a page looks like and how its CSS actually cascades (get_page_blocks gives the editable block tree; this gives the rendered result). Returns { rendered_html, internal_links[] }. Pass annotate:true to tag every element with data-block-id + data-block-type so you can map the rendered HTML back to the block to edit. For a clickable preview URL use get_preview_link instead.",
290
+ description: "Get the WHOLE page rendered as one self-contained HTML document — header partial + block-rendered body + footer partial, with the site's settings CSS variables, global styles, the tree-shaken block-CSS bundle, block JavaScript, the Extension runtime and the cookie-consent banner all included, exactly as the portal Preview shows it. This is the single artifact for UNDERSTANDING what a page looks like and how its CSS actually cascades (get_page_blocks gives the editable block tree; this gives the rendered result). Returns { rendered_html, internal_links[] }. Pass annotate:true to tag every element with data-block-id + data-block-type so you can map the rendered HTML back to the block to edit. For a clickable preview URL use get_preview_link instead.",
291
291
  inputSchema: {
292
292
  page_id: z.string(),
293
293
  annotate: z
@@ -118,7 +118,7 @@ export const partialTools = [
118
118
  },
119
119
  {
120
120
  name: 'find_pages_using_block',
121
- description: 'Pages that embed a given block via <x-include name="…">. For partial_id "header" or "footer" returns the full page list (they are auto-injected on every page). Use this to know the blast radius before editing a shared block.',
121
+ description: 'What references a global block, saved or in a draft: `pages` (core/global_block or <x-include name="…">), `templates` (page templates) and `global_blocks` (header, footer and other global blocks that nest it; a header or footer means every page). For partial_id "header" or "footer" returns the full page list (they are auto-injected on every page). Use this to know the blast radius before editing a shared block.',
122
122
  inputSchema: {
123
123
  partial_id: z.string(),
124
124
  version: versionParam,
@@ -24,7 +24,7 @@ export const reusableBlockTools = [
24
24
  },
25
25
  {
26
26
  name: 'detach_global_block',
27
- description: 'Replace a core/global_block reference on a page with an editable copy of the global block\'s blocks. Later edits to the global block no longer reach this page. Writes the page draft.',
27
+ description: 'Replace a core/global_block reference on a page with an editable copy of the global block\'s blocks. Copies the global block\'s unsaved draft on this version when it has one (what the editors show), otherwise the saved global block; `copied_from` says which. Later edits to the global block no longer reach this page. Writes the page draft.',
28
28
  inputSchema: {
29
29
  page_id: z.string(),
30
30
  block_id: z.string().describe('The id of the core/global_block reference block.'),