@typeroll/mcp-server 0.45.64 → 0.45.66

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.js CHANGED
@@ -11,6 +11,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
11
11
  import { z } from 'zod';
12
12
  import { ownerReviewTools } from './tools/owner-review.js';
13
13
  import { pageTools } from './tools/pages.js';
14
+ import { structuredDataTools } from './tools/structured-data.js';
14
15
  import { partialTools } from './tools/partials.js';
15
16
  import { contentTypeTools } from './tools/content-types.js';
16
17
  import { pageTemplateTools } from './tools/page-templates.js';
@@ -94,6 +95,9 @@ function effectFor(name) {
94
95
  || name === 'revoke_site_share'
95
96
  || name === 'revoke_organization_api_key'
96
97
  || name === 'create_organization_invite'
98
+ || name === 'set_organization_ai_key'
99
+ || name === 'remove_organization_ai_key'
100
+ || name === 'set_organization_ai_site_access'
97
101
  || name === 'disconnect_organization_publishing_provider'
98
102
  || name === 'connect_organization_cloudflare'
99
103
  || name === 'prepare_organization_media_storage'
@@ -138,7 +142,9 @@ The full playbook ships with this server — use it:
138
142
  0. First confirm the site, organization and version with the user. Personal
139
143
  connections require select_site and its site_context on subsequent calls.
140
144
  Use list_skills and read_skill for only the relevant recipe. Use read_guide
141
- sections when needed; do not load the whole manual by default.
145
+ sections when needed; do not load the whole manual by default. Everyday
146
+ edits (links, images, menus, site-wide changes, settings, analytics,
147
+ structured data) are in tr-edit-site.
142
148
  1. When the task is "build / migrate / redesign / brand a site", call
143
149
  list_skills FIRST, then read_skill <name> for the step-by-step recipe
144
150
  (tr-new-site, tr-migrate-wp, tr-brand, tr-blog, tr-responsive, …). These are
@@ -175,6 +181,10 @@ The full playbook ships with this server — use it:
175
181
  start from and then adapt is a block template (list_block_templates,
176
182
  insert_block_template copies it). Never duplicate shared content by hand.
177
183
  8. No site yet? With an org-scoped key, create_site bootstraps one.
184
+ 9. AI workflows (site_planning, content_generation, content_improvement,
185
+ schema_markup, create_site_and_plan) need the Organization's portal AI
186
+ key for the Site. On ai_not_enabled don't retry: do the work yourself
187
+ with the recipe named in start_workflow and tell the user you did.
178
188
 
179
189
  If anything here conflicts with what a tool returns, trust the tool. Every
180
190
  tool's own description carries its specifics.
@@ -191,6 +201,7 @@ export function buildServer(options) {
191
201
  ...skillTools,
192
202
  ...siteTools,
193
203
  ...pageTools,
204
+ ...structuredDataTools,
194
205
  ...ownerReviewTools,
195
206
  ...partialTools,
196
207
  ...blockTypeTools,
@@ -0,0 +1,217 @@
1
+ // Schema.org JSON-LD builders for Page.json_ld: FAQPage, Recipe and Event.
2
+ //
3
+ // One implementation for every caller. The MCP tools (tools/structured-data.ts)
4
+ // and the portal's built-in AI chat (portal/src/lib/anthropic.ts, imported as
5
+ // @typeroll/mcp-server/structured-data) both validate input with these zod
6
+ // shapes and build JSON-LD with these functions, so the two cannot drift. It
7
+ // lives in this package rather than @typeroll/shared because the standalone
8
+ // MCP package is published without @typeroll/shared, while the portal already
9
+ // depends on this package.
10
+ //
11
+ // mergeJsonLd() adds the new node to whatever the page already carries: a node
12
+ // of the same @type is replaced (re-running a tool updates it), other nodes are
13
+ // kept in an @graph, so an FAQ added later does not erase a LocalBusiness.
14
+ import { z } from 'zod';
15
+ import { zodToJsonSchema } from 'zod-to-json-schema';
16
+ /** Invalid input or an unusable existing json_ld; `issues` name the field. */
17
+ export class StructuredDataError extends Error {
18
+ issues;
19
+ constructor(issues) {
20
+ super(`Invalid structured data: ${issues.join('; ')}`);
21
+ this.issues = issues;
22
+ this.name = 'StructuredDataError';
23
+ }
24
+ }
25
+ const text = (max) => z.string().trim().min(1, 'Must not be empty.').max(max);
26
+ const httpUrl = z.string().trim().max(2048).url('Use an absolute http(s) URL.')
27
+ .refine((value) => /^https?:\/\//i.test(value), 'Use an absolute http(s) URL.');
28
+ const ISO_DURATION = /^P(?!$)(\d+Y)?(\d+M)?(\d+W)?(\d+D)?(T(?=\d)(\d+H)?(\d+M)?(\d+(?:\.\d+)?S)?)?$/;
29
+ const duration = z.string().trim().regex(ISO_DURATION, 'Use an ISO 8601 duration such as PT30M or PT1H15M.');
30
+ const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})(?:T([01]\d|2[0-3]):([0-5]\d)(?::([0-5]\d)(?:\.\d+)?)?(Z|[+-](?:[01]\d|2[0-3]):[0-5]\d)?)?$/;
31
+ function realDate(value) {
32
+ const m = ISO_DATE.exec(value);
33
+ if (!m)
34
+ return false;
35
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
36
+ const date = new Date(Date.UTC(y, mo - 1, d));
37
+ return date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d;
38
+ }
39
+ const isoDate = z.string().trim().refine(realDate, 'Use an ISO 8601 date or date-time, e.g. 2026-06-12 or 2026-06-12T19:00:00+02:00.');
40
+ const hasTime = (value) => value.includes('T');
41
+ const hasOffset = (value) => /(?:Z|[+-]\d{2}:\d{2})$/.test(value);
42
+ /** Input fields of add_faq_schema (besides page_id). */
43
+ export const faqSchemaShape = {
44
+ items: z.array(z.object({
45
+ question: text(500),
46
+ answer: text(5000).describe('Plain text or simple HTML, exactly as the page shows it.'),
47
+ }).strict()).min(1, 'Add at least one question.').max(100)
48
+ .describe('The questions and answers the page itself shows, in page order.'),
49
+ };
50
+ /** Input fields of add_recipe_schema (besides page_id). */
51
+ export const recipeSchemaShape = {
52
+ name: text(200),
53
+ description: text(1000).optional(),
54
+ image_url: httpUrl.optional().describe('Absolute image URL, e.g. the page\'s CDN image. Google needs an image for recipe rich results.'),
55
+ author: text(200).optional(),
56
+ prep_time: duration.optional().describe('ISO 8601 duration, e.g. PT30M.'),
57
+ cook_time: duration.optional().describe('ISO 8601 duration, e.g. PT1H.'),
58
+ total_time: duration.optional().describe('ISO 8601 duration, e.g. PT1H30M.'),
59
+ recipe_yield: text(100).optional().describe('e.g. "4 servings".'),
60
+ ingredients: z.array(text(500)).min(1, 'Add at least one ingredient.').max(100),
61
+ instructions: z.array(text(2000)).min(1, 'Add at least one step.').max(100).describe('One entry per step, in order.'),
62
+ };
63
+ /** Input fields of add_event_schema (besides page_id). */
64
+ export const eventSchemaShape = {
65
+ name: text(200),
66
+ description: text(1000).optional(),
67
+ start_date: isoDate.describe('ISO 8601, e.g. 2026-06-12T19:00:00+02:00. Include the UTC offset.'),
68
+ end_date: isoDate.optional().describe('ISO 8601; not before start_date.'),
69
+ location_name: text(200).optional(),
70
+ location_address: text(500).optional(),
71
+ image_url: httpUrl.optional(),
72
+ offers_url: httpUrl.optional().describe('Ticket URL.'),
73
+ offers_price: z.union([z.number().nonnegative(), z.string().trim().regex(/^\d+(?:\.\d+)?$/, 'Use a number such as 250 or 19.95.')])
74
+ .optional().describe('Ticket price as a number; 0 for a free event. Needs offers_currency.'),
75
+ offers_currency: z.string().trim().regex(/^[A-Za-z]{3}$/, 'Use an ISO 4217 code such as EUR or SEK.').optional()
76
+ .describe('ISO 4217, e.g. EUR.'),
77
+ };
78
+ function parse(schema, input) {
79
+ const result = schema.safeParse(input);
80
+ if (result.success)
81
+ return result.data;
82
+ throw new StructuredDataError(result.error.issues.map((i) => `${i.path.join('.') || 'input'}: ${i.message}`));
83
+ }
84
+ const CONTEXT = 'https://schema.org';
85
+ export function buildFaqJsonLd(input) {
86
+ const { items } = parse(z.object(faqSchemaShape), input);
87
+ return {
88
+ node: {
89
+ '@context': CONTEXT,
90
+ '@type': 'FAQPage',
91
+ mainEntity: items.map((it) => ({
92
+ '@type': 'Question',
93
+ name: it.question,
94
+ acceptedAnswer: { '@type': 'Answer', text: it.answer },
95
+ })),
96
+ },
97
+ warnings: [],
98
+ };
99
+ }
100
+ export function buildRecipeJsonLd(input) {
101
+ const r = parse(z.object(recipeSchemaShape), input);
102
+ const node = { '@context': CONTEXT, '@type': 'Recipe', name: r.name };
103
+ if (r.description)
104
+ node.description = r.description;
105
+ if (r.image_url)
106
+ node.image = r.image_url;
107
+ if (r.author)
108
+ node.author = { '@type': 'Person', name: r.author };
109
+ if (r.prep_time)
110
+ node.prepTime = r.prep_time;
111
+ if (r.cook_time)
112
+ node.cookTime = r.cook_time;
113
+ if (r.total_time)
114
+ node.totalTime = r.total_time;
115
+ if (r.recipe_yield)
116
+ node.recipeYield = r.recipe_yield;
117
+ node.recipeIngredient = r.ingredients;
118
+ node.recipeInstructions = r.instructions.map((step, i) => ({ '@type': 'HowToStep', position: i + 1, text: step }));
119
+ const warnings = r.image_url ? [] : ['No image_url: Google shows recipe rich results only for recipes with an image.'];
120
+ return { node, warnings };
121
+ }
122
+ export function buildEventJsonLd(input) {
123
+ const e = parse(z.object(eventSchemaShape).superRefine((v, ctx) => {
124
+ if (v.offers_price !== undefined && !v.offers_currency) {
125
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['offers_currency'], message: 'Required with offers_price.' });
126
+ }
127
+ if (v.end_date && hasTime(v.start_date) === hasTime(v.end_date) && hasOffset(v.start_date) === hasOffset(v.end_date)
128
+ && Date.parse(v.end_date) < Date.parse(v.start_date)) {
129
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['end_date'], message: 'Must not be before start_date.' });
130
+ }
131
+ }), input);
132
+ const node = { '@context': CONTEXT, '@type': 'Event', name: e.name, startDate: e.start_date };
133
+ if (e.end_date)
134
+ node.endDate = e.end_date;
135
+ if (e.description)
136
+ node.description = e.description;
137
+ if (e.image_url)
138
+ node.image = e.image_url;
139
+ if (e.location_name || e.location_address) {
140
+ node.location = {
141
+ '@type': 'Place',
142
+ ...(e.location_name ? { name: e.location_name } : {}),
143
+ ...(e.location_address ? { address: e.location_address } : {}),
144
+ };
145
+ }
146
+ if (e.offers_url || e.offers_price !== undefined) {
147
+ node.offers = {
148
+ '@type': 'Offer',
149
+ ...(e.offers_url ? { url: e.offers_url } : {}),
150
+ ...(e.offers_price !== undefined ? { price: String(e.offers_price) } : {}),
151
+ ...(e.offers_currency ? { priceCurrency: e.offers_currency.toUpperCase() } : {}),
152
+ availability: 'https://schema.org/InStock',
153
+ };
154
+ }
155
+ const warnings = [];
156
+ if (hasTime(e.start_date) && !hasOffset(e.start_date))
157
+ warnings.push('start_date has no UTC offset; add one (e.g. +02:00) so search engines show the right local time.');
158
+ if (!node.location)
159
+ warnings.push('No location: Google shows event rich results only for events with a location.');
160
+ return { node, warnings };
161
+ }
162
+ /** The three page structured-data helpers, keyed by kind. */
163
+ export const STRUCTURED_DATA = {
164
+ faq: { tool: 'add_faq_schema', type: 'FAQPage', shape: faqSchemaShape, build: buildFaqJsonLd },
165
+ recipe: { tool: 'add_recipe_schema', type: 'Recipe', shape: recipeSchemaShape, build: buildRecipeJsonLd },
166
+ event: { tool: 'add_event_schema', type: 'Event', shape: eventSchemaShape, build: buildEventJsonLd },
167
+ };
168
+ /**
169
+ * JSON Schema of a helper's input including `page_id`, for tool definitions
170
+ * outside MCP (the portal chat passes it to the model as input_schema).
171
+ */
172
+ export function structuredDataInputSchema(kind) {
173
+ const schema = zodToJsonSchema(z.object({ page_id: z.string().min(1), ...STRUCTURED_DATA[kind].shape }), { $refStrategy: 'none' });
174
+ const { $schema: _schema, ...rest } = schema;
175
+ return rest;
176
+ }
177
+ function typesOf(node) {
178
+ const t = node['@type'];
179
+ return Array.isArray(t) ? t.map(String) : t == null ? [] : [String(t)];
180
+ }
181
+ /**
182
+ * Combine a page's existing json_ld string with a new node. Nodes of the same
183
+ * @type are replaced; other nodes are kept. Returns the json_ld string to
184
+ * store. An existing value that is not a JSON object/array is refused rather
185
+ * than silently overwritten.
186
+ */
187
+ export function mergeJsonLd(existing, node) {
188
+ const raw = typeof existing === 'string' ? existing.trim() : '';
189
+ let nodes = [];
190
+ if (raw) {
191
+ let data;
192
+ try {
193
+ data = JSON.parse(raw);
194
+ }
195
+ catch {
196
+ throw new StructuredDataError(['json_ld: the page\'s existing json_ld is not valid JSON. Read the page, then fix or clear json_ld with update_page before adding structured data.']);
197
+ }
198
+ const list = Array.isArray(data) ? data
199
+ : data && typeof data === 'object' && Array.isArray(data['@graph']) ? data['@graph']
200
+ : [data];
201
+ if (!list.every((n) => n && typeof n === 'object' && !Array.isArray(n))) {
202
+ throw new StructuredDataError(['json_ld: the page\'s existing json_ld is not a JSON-LD object or list of objects. Fix or clear it with update_page first.']);
203
+ }
204
+ nodes = list;
205
+ }
206
+ const newTypes = typesOf(node);
207
+ const kept = nodes.filter((n) => !typesOf(n).some((t) => newTypes.includes(t)));
208
+ const replaced = nodes.length - kept.length;
209
+ if (!kept.length)
210
+ return { json_ld: JSON.stringify(node), replaced, kept: 0 };
211
+ const strip = ({ '@context': _context, ...rest }) => rest;
212
+ return {
213
+ json_ld: JSON.stringify({ '@context': CONTEXT, '@graph': [...kept.map(strip), strip(node)] }),
214
+ replaced,
215
+ kept: kept.length,
216
+ };
217
+ }
@@ -1,7 +1,8 @@
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.
1
+ // Access tools — API keys, cross-organization site sharing, organization
2
+ // invites and the Organization's own AI key for the portal assistant. Each
3
+ // wraps the authenticated public API with the same permission checks as the
4
+ // portal: a key never mints a key or a grant that reaches further than it
5
+ // does.
5
6
  import { z } from 'zod';
6
7
  import { ok, withErrorBoundary } from './helpers.js';
7
8
  const keyId = z.string().regex(/^[a-f0-9]{12}$/).describe('Key id (the 12-hex prefix) from list_api_keys / list_organization_api_keys.');
@@ -72,4 +73,35 @@ export const accessTools = [
72
73
  inputSchema: { ttl_days: z.number().int().min(1).max(30).optional() },
73
74
  handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('organization/invites', args))),
74
75
  },
76
+ {
77
+ name: 'read_organization_ai_settings',
78
+ noSite: true,
79
+ description: 'Read the Organization\'s built-in AI assistant settings: whether its own Anthropic API key is set (last four characters, verified_at, who set it and when — never the key), site_access ({ mode: "all" | "selected", site_ids }) and every Site of the Organization with ai_enabled. The portal AI chat appears only on Sites with ai_enabled. This setting never limits MCP or the API: you can edit every Site you have access to. Requires an organization API key, or a personal connection in an Organization where the person is an owner or admin.',
80
+ inputSchema: {},
81
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('organization/ai'))),
82
+ },
83
+ {
84
+ name: 'set_organization_ai_key',
85
+ noSite: true,
86
+ description: 'Add or replace the Organization\'s own Anthropic API key for the portal\'s built-in AI assistant. Typeroll checks the key with Anthropic before saving: a rejected key returns 422 ai_key_rejected and an unreachable Anthropic 502 ai_key_unverified, and nothing is stored. The key is stored encrypted and never returned. A first key enables no Sites; replacing a key keeps the Site choice. Only call this with a key the user gave you for this purpose and their explicit go-ahead; prefer that an owner or admin enters it in the portal (Organization settings → AI assistant) so it does not pass through this conversation. Never repeat the key back. Requires an organization API key.',
87
+ inputSchema: { api_key: z.string().min(20).max(512).describe('The Anthropic API key (starts with sk-ant-).') },
88
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPut('organization/ai/key', { api_key: args.api_key }))),
89
+ },
90
+ {
91
+ name: 'remove_organization_ai_key',
92
+ noSite: true,
93
+ description: 'Remove the Organization\'s Anthropic API key. The portal AI chat disappears from every Site and the Site choice starts over; MCP and the API are unaffected. Requires an organization API key and the user\'s explicit go-ahead.',
94
+ inputSchema: {},
95
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootDelete('organization/ai/key'))),
96
+ },
97
+ {
98
+ name: 'set_organization_ai_site_access',
99
+ noSite: true,
100
+ description: 'Choose which of the Organization\'s own Sites show the portal AI chat: mode "all" (every Site, including Sites created later) or mode "selected" with site_ids from read_organization_ai_settings (an empty list turns it off everywhere). A Site shared in from another Organization follows that owner\'s setting. Needs a key first (409 ai_key_required). Requires an organization API key.',
101
+ inputSchema: {
102
+ mode: z.enum(['all', 'selected']),
103
+ site_ids: z.array(z.string()).max(1000).optional().describe('Required when mode is "selected".'),
104
+ },
105
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPut('organization/ai/site-access', args.mode === 'all' ? { mode: 'all' } : { mode: 'selected', site_ids: args.site_ids ?? [] }))),
106
+ },
75
107
  ];
@@ -15,7 +15,8 @@ export const appTools = [
15
15
  },
16
16
  {
17
17
  name: 'read_app',
18
- description: 'Read one Typeroll app by registry id, including its field schema and masked state. Use list_apps to discover ids and required config fields. Secret values are never returned. For setup guidance without admin access, use read_app_documentation. Admin permission required.',
18
+ description: 'Read one Typeroll app by registry id, including its field schema and masked state. Use list_apps to discover ids and required config fields. Secret values are never returned. For setup guidance without admin access, use read_app_documentation. Admin permission required. ' +
19
+ 'For analytics, `setup` reports its Cloudflare Web Analytics site: status "ready" with the account it lives in (`account.owner` "organization" = the Cloudflare account of the site\'s Hosting Group, used for customer publishing; "installation" = the installation\'s own account, used for managed and self-hosted sites), or "action_required" with `code`, `message`, `next_step` and optional `settings_url`.',
19
20
  inputSchema: {
20
21
  app_id: z.string().min(1).describe('Registry id returned by list_apps, for example analytics or integrations.'),
21
22
  },
@@ -25,7 +26,9 @@ export const appTools = [
25
26
  },
26
27
  {
27
28
  name: 'update_app',
28
- description: 'Enable, configure, or disable any registered Typeroll app through the same admin API key used for publishing. Read the app first and send config keys from its field schema. Omitted fields preserve existing values, including encrypted secrets; secret values are masked on reads and encrypted server-side on writes. Analytics provisioning runs server-side when enabled. If affects_build is true, trigger_deploy is required to publish the change. For setup guidance without admin access, use read_app_documentation. Admin permission required.',
29
+ description: 'Enable, configure, or disable any registered Typeroll app through the same admin API key used for publishing. Read the app first and send config keys from its field schema. Omitted fields preserve existing values, including encrypted secrets; secret values are masked on reads and encrypted server-side on writes. If affects_build is true, trigger_deploy is required to publish the change. For setup guidance without admin access, use read_app_documentation. Admin permission required. ' +
30
+ 'Analytics: enabling creates the Cloudflare Web Analytics site in the account that hosts the site (customer publishing: the Hosting Group\'s Cloudflare connection, never the installation\'s account) and fills beacon_token and site_tag; a pasted beacon_token is resolved to its site_tag in that same account. ' +
31
+ 'When no beacon could be set up, the call fails with HTTP 409, a stable `code` (for example analytics_cloudflare_access_required, analytics_cloudflare_connection_required, analytics_host_required, analytics_provider_error, analytics_token_not_in_account) and `details.next_step`; `details.saved` says whether the enabled state was stored. Relay the next step to the person (approving Analytics access needs an organization owner or admin in the browser), then call update_app again. A 200 response carries `setup` too.',
29
32
  inputSchema: {
30
33
  app_id: z.string().min(1).describe('Registry id returned by list_apps.'),
31
34
  enabled: z.boolean(),
@@ -190,22 +190,26 @@ export const domainTools = [
190
190
  },
191
191
  {
192
192
  name: 'approve_publishing_domain_cutover',
193
- description: 'Approve switching website traffic to the verified frozen deployment. Read publishing domains first and review DNS requirements and certificate readiness. Existing traffic is preserved until validation succeeds. External DNS management remains the caller’s responsibility.',
194
- inputSchema: { revision: z.string(), candidate_id: z.string() },
193
+ description: 'Approve switching website traffic to the verified frozen deployment. Read publishing domains first and review DNS requirements, certificate readiness and redirect_preparations. Existing traffic is preserved until validation succeeds. With automatic DNS, redirect hosts switch after the website host: Typeroll ensures their zone redirect rule, then changes their reviewed record to a proxied placeholder. The publication goes live only after every redirect host answers 301 to the website host with the same path and query. When preparation.validation_blocker.resolution is accept_validation_window, Cloudflare can validate the website host only after its one existing record points at Pages: approving with accept_validation_window: true lets Typeroll change that record, and the host does not answer until Cloudflare validates it (Cloudflare does not document how long; previous_records holds the rollback). Get the user\'s explicit go-ahead for that window and choose a quiet time. External DNS management remains the caller’s responsibility.',
194
+ inputSchema: {
195
+ revision: z.string(), candidate_id: z.string(),
196
+ accept_validation_window: z.boolean().optional().describe('Accept the interruption until Cloudflare validates the website host after its single reviewed record changes. Only valid when preparation.validation_blocker.resolution is accept_validation_window.'),
197
+ },
195
198
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.post(siteId, 'publishing/cutover', args))),
196
199
  },
197
200
  {
198
201
  name: 'read_publishing_domains',
199
- description: 'Read the desired and active website and media hosts, retained media aliases, DNS management mode and domain configuration revision. Saving intent does not switch traffic.',
202
+ description: 'Read the desired and active website, redirect and media hosts, retained media aliases, DNS management mode and domain configuration revision. host_checks lists each future host with its Cloudflare zone and structured blockers and warnings (code, host, message), for example apex_zone_required (an apex such as example.com needs an active Cloudflare zone in the hosting account), zone_not_active (the zone is pending until its nameservers change) or redirect_zone_required. Resolve blockers before preparing. preparation and redirect_preparations hold DNS records, the zone redirect rule and certificate state after preparation. Saving intent does not switch traffic.',
200
203
  inputSchema: {},
201
204
  handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'publishing/domains'))),
202
205
  },
203
206
  {
204
207
  name: 'set_publishing_domains',
205
- description: 'Save future website and media hosts before preparing a deployment. Requires the current revision from read_publishing_domains. This does not publish content or change traffic DNS. Choose external DNS when your agent manages Cloudflare independently.',
208
+ description: 'Save future website, redirect and media hosts before preparing a deployment. Requires the current revision from read_publishing_domains. This does not publish content or change traffic DNS. Choose external DNS when your agent manages Cloudflare independently. redirect_hosts (for example www.example.com when example.com is the website host) answer 301 to the website host, keeping path and query, through a Single Redirect rule in the host\'s Cloudflare zone; they are switched and verified with the website. The response includes host_checks: resolve its blockers before preparing.',
206
209
  inputSchema: {
207
210
  revision: z.string(), website_host: z.string().nullable(), media_host: z.string().nullable(),
208
211
  media_path_prefix: z.string().optional().describe('Empty on a separate media host; /media when using the website host.'),
212
+ redirect_hosts: z.array(z.string()).max(4).optional().describe('Hostnames that redirect permanently to website_host, such as www.example.com. Omit or send [] for none.'),
209
213
  dns_mode: z.enum(['automatic', 'external']),
210
214
  },
211
215
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.put(siteId, 'publishing/domains', args))),
@@ -233,7 +233,7 @@ export const migrationTools = [
233
233
  },
234
234
  {
235
235
  name: 'get_migration_launch_report',
236
- description: 'Return the launch decision report with separate inventory, exclusion-reason, internal-link, deployed URL parity, deploy freshness, reviewed SEO/content parity and migration fidelity requirements. launch_ready is false when any evidence is absent, incomplete, failed, targets an old origin, or predates the latest hosted deploy. Use this final gate instead of treating zero unhandled URLs as launch readiness.',
236
+ description: 'Return the launch decision report with separate inventory, exclusion-reason, internal-link, deployed URL parity, deploy freshness, reviewed SEO/content parity, migration fidelity and script requirements. scripts lists copied source-platform scripts (WordPress plugins, theme, jQuery, page builders, cookie plugins), tracking code outside the consent gate, and analytics/advertising integrations without the cookie banner; each blocks launch. launch_ready is false when any evidence is absent, incomplete, failed, targets an old origin, or predates the latest hosted deploy. Use this final gate instead of treating zero unhandled URLs as launch readiness.',
237
237
  handler: withErrorBoundary(async (_args, { client, siteId }) => {
238
238
  const res = await client.get(siteId, 'migration-report');
239
239
  return ok(res);
@@ -64,8 +64,8 @@ export const settingsTools = [
64
64
  sitewide_nofollow: z.boolean().optional().describe('Ask search engines not to follow links. Defaults to false.'),
65
65
  image_sizes_default: z.string().optional().describe('Site-wide default `sizes` attribute for responsive-image <picture> output, e.g. "(max-width: 640px) 360px, 560px". Tells the browser how wide images actually render so it stops over-fetching the larger srcset variant. A page can override via its own image_sizes_default; a per-<img> `sizes` attribute wins over both. Leave unset for the generic "(max-width: 768px) 100vw, 800px".'),
66
66
  // Scriptable surfaces. Trusted because the caller has an API key.
67
- scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page. Use for analytics, fonts, third-party CSS links.'),
68
- scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page. Use for chat widgets, deferred analytics.'),
67
+ scripts_head: z.string().optional().describe('Raw HTML injected into <head> on every page, before consent. Use for fonts and third-party CSS links. Not for analytics or advertising tags: set those by ID in the Integrations app with cookie_consent enabled (or, for a tag the catalog lacks, cookie_consent.scripts_optional). In a migration, never copy the source site\'s plugin, theme or WordPress scripts here.'),
68
+ scripts_body_end: z.string().optional().describe('Raw HTML injected just before </body> on every page, before consent. Use for small site-owned scripts. Not for analytics or advertising tags (Integrations app with cookie_consent enabled) or for scripts copied from the source site in a migration.'),
69
69
  custom_css: z.string().optional().describe('Global CSS in a <style> in <head>, after block CSS and before the page\'s own custom CSS and the template base stylesheet (reset + global). Base rules for :root tokens (spacing, radius, container widths), body font and line-height load after it, so override those with a more specific selector (for example html:root or body.page) rather than a bare :root/body rule. Preview uses the same order. Lets you define site-wide design tokens (CSS variables, @media queries, :hover states) without inlining on every element. Prefer named styles (create_style) for anything a style can express. Syntax errors (unbalanced braces, unclosed comments or strings, expression()/javascript:) refuse the write; selectors on platform markup ([data-block], .block-*) come back as warnings because they can change between render versions — target s-<style> classes or block custom_class instead.'),
70
70
  default_og_image: z.string().optional().describe('Site-wide fallback og:image URL for pages without their own social image. Empty string clears.'),
71
71
  twitter_handle: z.string().optional().describe('Twitter/X handle for twitter:site, with or without the leading @ (stored without it). Empty string clears.'),
@@ -53,7 +53,7 @@ export const siteTools = [
53
53
  },
54
54
  {
55
55
  name: 'get_site_insights',
56
- description: 'Read Analytics insights over the last 7, 30, or 90 days: traffic, AI-assistant referrals, and first-party conversion events grouped by event, destination, source, and campaign. Traffic is powered by Cloudflare Web Analytics; conversions come from validated attribution-funnel click events. The response carries a traffic `status`: "ok" with numbers, or "app_disabled" / "not_configured" / "no_site_tag" / "no_data". Conversion data can still be present when the traffic provider is unavailable.',
56
+ description: 'Read Analytics insights over the last 7, 30, or 90 days: traffic, AI-assistant referrals, and first-party conversion events grouped by event, destination, source, and campaign. Traffic is powered by Cloudflare Web Analytics; conversions come from validated attribution-funnel click events. The response carries a traffic `status`: "ok" with numbers, "no_data" for a linked site without traffic, or "app_disabled" / "not_configured" / "no_site_tag" / "connection_required" / "access_required" / "account_mismatch" / "provider_error" with a `reason` ({code, message, next_step, settings_url}). Traffic is read from the same Cloudflare account the beacon belongs to (`account`): the site\'s Hosting Group account for customer publishing, the installation\'s account otherwise. Conversion data can still be present when the traffic provider is unavailable.',
57
57
  inputSchema: {
58
58
  days: z.union([z.literal(7), z.literal(30), z.literal(90)]).optional().describe('Look-back window in days (default 30).'),
59
59
  },
@@ -0,0 +1,64 @@
1
+ // Structured-data tools: FAQPage, Recipe and Event JSON-LD on a page.
2
+ //
3
+ // The same inputs, validation and builders as the portal AI chat's tools of
4
+ // the same names (../structured-data.ts). Each tool reads the page's draft
5
+ // view, merges the new node into its json_ld (same @type replaced, others
6
+ // kept) and writes json_ld back through update_page's route, so the change
7
+ // follows the buffer model like any other content write.
8
+ import { z } from 'zod';
9
+ import { ok, versionParam, withErrorBoundary } from './helpers.js';
10
+ import { mergeJsonLd, STRUCTURED_DATA, StructuredDataError } from '../structured-data.js';
11
+ const DESCRIPTIONS = {
12
+ faq: 'Add FAQPage JSON-LD to a page that visibly presents these questions and answers (Google penalizes FAQ markup for content the page does not show). Copy the questions and answers from the page.',
13
+ recipe: 'Add Recipe JSON-LD to a recipe page: name, ingredients and steps (required), plus description, image, author, ISO 8601 durations (PT30M) and yield.',
14
+ event: 'Add Event JSON-LD to a page about one upcoming event: name and ISO 8601 start_date with UTC offset (required), end, place, image and ticket offer. A price needs offers_currency.',
15
+ };
16
+ const COMMON = ' Validates the input and builds the schema.org JSON-LD for you; prefer this to hand-writing json_ld. It is merged into the page\'s json_ld: an existing node of the same @type is replaced, other nodes (e.g. LocalBusiness) are kept in an @graph. Returns the stored JSON-LD and warnings about rich-result eligibility. BUFFER MODEL: lands in the page\'s unsaved draft; pass save:true or commit_working_copy after the user approves. Needs write permission.';
17
+ function v(version) {
18
+ return version ? { version } : undefined;
19
+ }
20
+ function invalid(error) {
21
+ return { content: [{ type: 'text', text: JSON.stringify({ error: error.message, issues: error.issues }, null, 2) }], isError: true };
22
+ }
23
+ export const structuredDataTools = Object.keys(STRUCTURED_DATA).map((kind) => {
24
+ const spec = STRUCTURED_DATA[kind];
25
+ return {
26
+ name: spec.tool,
27
+ description: DESCRIPTIONS[kind] + COMMON,
28
+ inputSchema: {
29
+ page_id: z.string().min(1),
30
+ ...spec.shape,
31
+ save: z.boolean().optional().describe('Also save (commit) the draft in the same call, for a change the user already approved.'),
32
+ version: versionParam,
33
+ },
34
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
35
+ const { page_id, save, version, ...input } = args;
36
+ let built;
37
+ let merged;
38
+ const path = `pages/${encodeURIComponent(page_id)}`;
39
+ try {
40
+ built = spec.build(input);
41
+ // The draft view, so an unsaved json_ld edit is merged, not lost.
42
+ const current = await client.get(siteId, path, v(version));
43
+ merged = mergeJsonLd(current.page?.json_ld, built.node);
44
+ }
45
+ catch (error) {
46
+ if (error instanceof StructuredDataError)
47
+ return invalid(error);
48
+ throw error;
49
+ }
50
+ await client.patch(siteId, path, { json_ld: merged.json_ld, ...(save ? { save: true } : {}) }, v(version));
51
+ return ok({
52
+ ok: true,
53
+ page_id,
54
+ schema_type: spec.type,
55
+ replaced_same_type: merged.replaced,
56
+ kept_other_nodes: merged.kept,
57
+ saved: !!save,
58
+ warnings: built.warnings,
59
+ json_ld: JSON.parse(merged.json_ld),
60
+ next: save ? 'Saved. Deploy when the user wants it live.' : 'Unsaved draft: preview with get_preview_link include_working_copy:true, then save with commit_working_copy when approved.',
61
+ });
62
+ }),
63
+ };
64
+ });
@@ -4,6 +4,10 @@
4
4
  // Site together with its first workflow.
5
5
  import { z } from 'zod';
6
6
  import { ok, versionParam, withErrorBoundary } from './helpers.js';
7
+ // Run by the portal with the Organization's own AI key; without access for the
8
+ // Site the run fails with failure_code ai_not_enabled. The agent then does the
9
+ // work itself with the named recipe instead of retrying.
10
+ const AI_WORKFLOWS = 'AI workflows — site_planning, content_generation, content_improvement and schema_markup — call the model with the Organization\'s own AI key and need AI access for this Site; without it the run fails with failure_code "ai_not_enabled" (failure_reason: the AI assistant is not enabled for this site). Do not retry or work around it: you are an AI agent, so do the same work yourself and tell the user you did — site_planning: tr-new-site (propose the page structure, get approval, then build); content_generation: tr-content-write (draft page); content_improvement: tr-content-write on the weakest published pages (thin text, missing seo_title/seo_description, stale), as unsaved drafts for review; schema_markup: tr-seo step 5 (add_faq_schema, add_recipe_schema, add_event_schema or json_ld). seo_audit still runs without AI and returns its issue list unprioritized. Enabling the portal AI is the Organization owner\'s or admin\'s choice, never a prerequisite for you.';
7
11
  const WORKFLOW_TYPES = [
8
12
  'migration', 'site_planning', 'seo_audit', 'content_improvement', 'link_check',
9
13
  'performance_audit', 'content_generation', 'schema_markup', 'url_parity', 'rebuild_deploy',
@@ -11,13 +15,13 @@ const WORKFLOW_TYPES = [
11
15
  export const workflowTools = [
12
16
  {
13
17
  name: 'list_workflows',
14
- description: 'List the workflow types this Site can run (label, description, steps, required_permission) and its most recent runs with status. Read only. Migration, site planning and URL parity pause for review; check get_workflow for paused_for_review.',
18
+ description: 'List the workflow types this Site can run (label, description, steps, required_permission) and its most recent runs with status. Read only. Migration, site planning and URL parity pause for review; check get_workflow for paused_for_review. Site planning, content generation, content improvement and schema markup need the Organization\'s AI key for this Site (see start_workflow).',
15
19
  inputSchema: { limit: z.number().int().min(1).max(100).optional() },
16
20
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, 'workflows', args.limit ? { limit: args.limit } : undefined))),
17
21
  },
18
22
  {
19
23
  name: 'start_workflow',
20
- description: 'Start a server-side workflow on this Site; it runs in the background and returns workflow_id. Poll get_workflow. Config per type (same fields as the portal form): migration { wp_url, helper_api_key? } (needs verified organization import storage); site_planning { business_description }; content_generation { topic, tone?, audience? }; content_improvement { limit? }; url_parity { target_origin?, source_origin?, check_source?, statuses? }; rebuild_deploy { environment: staging | production }; seo_audit, link_check, performance_audit and schema_markup take none. Needs write permission; rebuild_deploy publishes and needs admin. Workflows that change content write to the given version (default main) — get the user\'s agreement first.',
24
+ description: 'Start a server-side workflow on this Site; it runs in the background and returns workflow_id. Poll get_workflow. Config per type (same fields as the portal form): migration { wp_url, helper_api_key? } (needs verified organization import storage); site_planning { business_description }; content_generation { topic, tone?, audience? }; content_improvement { limit? }; url_parity { target_origin?, source_origin?, check_source?, statuses? }; rebuild_deploy { environment: staging | production }; seo_audit, link_check, performance_audit and schema_markup take none. Needs write permission; rebuild_deploy publishes and needs admin. Workflows that change content write to the given version (default main) — get the user\'s agreement first. ' + AI_WORKFLOWS,
21
25
  inputSchema: {
22
26
  type: z.enum(WORKFLOW_TYPES),
23
27
  config: z.record(z.unknown()).optional(),
@@ -27,7 +31,7 @@ export const workflowTools = [
27
31
  },
28
32
  {
29
33
  name: 'get_workflow',
30
- description: 'Read one workflow run: status (pending | running | paused_for_review | completed | failed), current step, progress, log, review_message/review_data when paused, results and failure_reason. Credential config is masked.',
34
+ description: 'Read one workflow run: status (pending | running | paused_for_review | completed | failed), current step, progress, log, review_message/review_data when paused, results, failure_reason and failure_code (newer Core; older Core reports failure_reason only). Credential config is masked. failure_code "ai_not_enabled" (or a failure_reason saying the AI assistant is not enabled for this site) means the Site has no portal AI access: do the work yourself with the recipe named in start_workflow instead of retrying.',
31
35
  inputSchema: { workflow_id: z.string() },
32
36
  handler: withErrorBoundary(async (args, { client, siteId }) => ok(await client.get(siteId, `workflows/${encodeURIComponent(args.workflow_id)}`))),
33
37
  },
@@ -51,7 +55,7 @@ export const workflowTools = [
51
55
  {
52
56
  name: 'create_site_and_plan',
53
57
  noSite: true,
54
- description: 'Create a new Site in the Organization and start AI site planning from a business description, like "Plan a new site with AI" on the New site page. Requires an organization API key. The plan pauses for review: read it with get_workflow and approve_workflow only after the user agrees.',
58
+ description: 'Create a new Site in the Organization and start AI site planning from a business description, like "Plan a new site with AI" on the New site page. Requires an organization API key. Needs the Organization\'s own AI key with site access "all" (read_organization_ai_settings); otherwise it answers 403 ai_not_enabled and creates nothing — then create_site a blank Site and plan it yourself with tr-new-site (propose the pages, get the user\'s approval, build), and say that you did; enabling the portal AI is the owner\'s or admin\'s choice, not a prerequisite. The plan pauses for review: read it with get_workflow and approve_workflow only after the user agrees.',
55
59
  inputSchema: {
56
60
  name: z.string().min(1),
57
61
  business_description: z.string().min(1).describe('What the business does, for whom, and what sets it apart.'),
package/dist/version.js CHANGED
@@ -8,4 +8,4 @@
8
8
  //
9
9
  // Keep it in lockstep with package.json: tests/version.test.ts asserts
10
10
  // VERSION === package.json.version, so a bump that forgets this line fails CI.
11
- export const VERSION = '0.45.64';
11
+ export const VERSION = '0.45.66';
package/dist/workspace.js CHANGED
@@ -57,6 +57,17 @@ Public documentation: https://typeroll.com/docs/llms.txt
57
57
  - Never edit generated publishing source to change CMS content. Do not overwrite
58
58
  current CMS or owner answers from an old local snapshot.
59
59
 
60
+ ## Everyday requests
61
+
62
+ - Links, images, menus, settings, analytics and site-wide changes: read the
63
+ tr-edit-site recipe. Shared content becomes one global block, not N edits.
64
+ - FAQ, recipe and event pages: add_faq_schema, add_recipe_schema and
65
+ add_event_schema build validated JSON-LD (see tr-seo); don't hand-write it.
66
+ - A blank site or the starter design: tr-starter-design lists its variables.
67
+ - The portal's built-in AI is optional. When an AI workflow (site planning,
68
+ content generation or improvement, schema markup) reports ai_not_enabled,
69
+ do the work yourself with the recipe it names and say so; don't retry.
70
+
60
71
  Store credentials in the client's private settings or injected environment.
61
72
  Run the workspace doctor after setup and after changing the binding.
62
73
  `;
@@ -69,7 +80,7 @@ export const workspaceTemplates = {
69
80
  'decisions.md': '# Decisions\n\nRecord date, decision, reason, approved scope and alternatives when useful.\n',
70
81
  'sources/README.md': '# Sources\n\nStore research references and copy proposals. Record source URLs and dates.\nPrivate extracts belong in ignored private/ or an approved external store.\nDo not treat local drafts as current CMS content or commit media libraries.\n',
71
82
  'scripts/README.md': '# Project automation\n\nKeep repeatable imports and QA scripts here. Use the authenticated REST API\nfor scripted work; keep credentials outside files and inject them at runtime.\nMCP remains the interactive interface. Typeroll currently has no general site CRUD CLI.\n',
72
- 'qa/checklist.md': '# Verification\n\n- Confirm Site, Version and deployed/source identity.\n- Read back saved content and inspect desktop/mobile previews.\n- Exercise menus, keyboard focus, links and enabled app states.\n- Verify routes, metadata, media and publishing readiness before launch.\n- Record tested pages, viewports, results and unresolved issues.\n- Store screenshots and private runtime reports in ignored reports/.\n- Publishing validation does not prove visual or editorial completeness.\n',
83
+ 'qa/checklist.md': '# Verification\n\n- Confirm Site, Version and deployed/source identity.\n- Read back saved content and inspect desktop/mobile previews.\n- Exercise menus, keyboard focus, links and enabled app states.\n- Verify routes, metadata, media and publishing readiness before launch.\n- Check that structured data (FAQ, recipe, event) matches what each page shows.\n- Record tested pages, viewports, results and unresolved issues.\n- Store screenshots and private runtime reports in ignored reports/.\n- Publishing validation does not prove visual or editorial completeness.\n',
73
84
  };
74
85
  export async function readWorkspace(dir) {
75
86
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.64",
3
+ "version": "0.45.66",
4
4
  "description": "Typeroll CMS MCP server – connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,7 +21,9 @@
21
21
  "./server": "./src/server.ts",
22
22
  "./client": "./src/client.ts",
23
23
  "./version": "./src/version.ts",
24
- "./tools/helpers": "./src/tools/helpers.ts"
24
+ "./tools/helpers": "./src/tools/helpers.ts",
25
+ "./structured-data": "./src/structured-data.ts",
26
+ "./bundled-content": "./src/bundled-content.ts"
25
27
  },
26
28
  "files": [
27
29
  "dist",
@@ -47,7 +49,7 @@
47
49
  },
48
50
  "devDependencies": {
49
51
  "typescript": "^5.6.0",
50
- "vitest": "^4.1.7"
52
+ "vitest": "^4.1.12"
51
53
  },
52
54
  "publishConfig": {
53
55
  "access": "public"
package/skills/README.md CHANGED
@@ -30,6 +30,8 @@ instructions. Read the relevant recipe when the task calls for it.
30
30
 
31
31
  | File | When it triggers | What it does |
32
32
  |---|---|---|
33
+ | `tr-edit-site.md` | "change this page", "add a link to the menu", "put our phone number on every page" | Confirm the site, find the page, everyday requests (links, images, menus, site-wide changes as global blocks, settings, analytics), preview and save. |
34
+ | `tr-starter-design.md` | blank site, or one that still follows the starter design | The default CSS variables, spacing, radii, container and header/footer conventions to match. |
33
35
  | `tr-new-site.md` | "create a new site", "bootstrap a site for…" | Settings → header/footer partials → homepage → inner pages → deploy. Full setup recipe. |
34
36
  | `tr-brand.md` | "create a brand", "choose colors", "design the look"| Palette recipes by mood, typography pairings, CSS variable setup, preview. |
35
37
  | `tr-redesign-branch.md`| "redesign", "modernize", anything site-wide-design | Branch-isolated work with preview links, merge when approved. |
@@ -45,7 +47,7 @@ instructions. Read the relevant recipe when the task calls for it.
45
47
  | `tr-forms.md` | "contact form", "add a form", "booking form" | Form definition → embed HTML with signed token → inline JS feedback → deploy. |
46
48
  | `tr-custom-blocks.md` | "make a block for…", "icon list", a section shape the core blocks lack | Starter or composition from core blocks with fields and bindings → validate → preview → create → place. |
47
49
  | `tr-page-template.md` | Reusable Page templates: audio players, chapter lists, guest cards, image galleries — anything needing loops/nested data | Compose native blocks around `template_content_slot`; bind Page metadata, structured fields and references. |
48
- | `tr-seo.md` | "SEO", "meta descriptions", "structured data" | Audit → fix titles/descriptions → OG images → JSON-LD → robots.txt → deploy. |
50
+ | `tr-seo.md` | "SEO", "meta descriptions", "structured data", "FAQ schema" | Audit → fix titles/descriptions → URL conventions → OG images → JSON-LD (`add_faq_schema`, `add_recipe_schema`, `add_event_schema`) → robots.txt → deploy. |
49
51
 
50
52
  ### Import content
51
53