@typeroll/mcp-server 0.45.65 → 0.45.67

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';
@@ -141,7 +142,9 @@ The full playbook ships with this server — use it:
141
142
  0. First confirm the site, organization and version with the user. Personal
142
143
  connections require select_site and its site_context on subsequent calls.
143
144
  Use list_skills and read_skill for only the relevant recipe. Use read_guide
144
- 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.
145
148
  1. When the task is "build / migrate / redesign / brand a site", call
146
149
  list_skills FIRST, then read_skill <name> for the step-by-step recipe
147
150
  (tr-new-site, tr-migrate-wp, tr-brand, tr-blog, tr-responsive, …). These are
@@ -178,6 +181,10 @@ The full playbook ships with this server — use it:
178
181
  start from and then adapt is a block template (list_block_templates,
179
182
  insert_block_template copies it). Never duplicate shared content by hand.
180
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.
181
188
 
182
189
  If anything here conflicts with what a tool returns, trust the tool. Every
183
190
  tool's own description carries its specifics.
@@ -194,6 +201,7 @@ export function buildServer(options) {
194
201
  ...skillTools,
195
202
  ...siteTools,
196
203
  ...pageTools,
204
+ ...structuredDataTools,
197
205
  ...ownerReviewTools,
198
206
  ...partialTools,
199
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
+ }
@@ -28,7 +28,7 @@ export const appTools = [
28
28
  name: 'update_app',
29
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
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.',
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 Cloudflare permissions needs an organization owner or admin in the browser: one "Approve update" per Cloudflare account on the Publishing page, or on older deployments "Approve Analytics access" on the module), then call update_app again. A 200 response carries `setup` too. Check diagnose_organization_cloudflare_connection permissions first to see whether the hosting account may create Web Analytics sites. To choose several Sites at once use set_organization_analytics.',
32
32
  inputSchema: {
33
33
  app_id: z.string().min(1).describe('Registry id returned by list_apps.'),
34
34
  enabled: z.boolean(),
@@ -41,4 +41,18 @@ export const appTools = [
41
41
  }));
42
42
  }),
43
43
  },
44
+ {
45
+ name: 'list_organization_analytics', noSite: true,
46
+ description: 'List every active Site of the Organization with Analytics on or off, its Hosting Group and setup (ready, or code, message and next_step), plus cloudflare_permissions: per Hosting Group, whether its Cloudflare connection may create Web Analytics sites (analytics_provisioning), read reports (analytics_reports) and create redirect rules (redirect_rules), the exact missing scopes and the one permission update. Approving Cloudflare permissions enables Analytics for no Site. Requires an organization API key; never returns beacon tokens or credentials.',
47
+ inputSchema: {},
48
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('organization/analytics'))),
49
+ },
50
+ {
51
+ name: 'set_organization_analytics', noSite: true,
52
+ description: 'Turn Analytics on or off for the chosen Sites of the Organization (at most 50 per call). Only listed Sites change. Each runs the same path as update_app: enabling creates the Site\'s Cloudflare Web Analytics site in its hosting account and fills beacon_token and site_tag; the beacon appears after the Site\'s next publication (trigger_deploy). Returns one result per Site: status ready, action_required (with code, message, next_step and settings_url; Analytics is saved as on), disabled, or error (nothing changed). Ask the person which Sites to measure before enabling. Requires an organization API key.',
53
+ inputSchema: {
54
+ sites: z.array(z.object({ site_id: z.string().min(1), enabled: z.boolean() })).min(1).max(50).describe('Sites to change, by site_id from list_organization_analytics.'),
55
+ },
56
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('organization/analytics', { sites: args.sites }))),
57
+ },
44
58
  ];
@@ -30,7 +30,7 @@ export const domainTools = [
30
30
  },
31
31
  {
32
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, github_setup (app_configured, encryption_available) 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. When GitHub is not connected, call diagnose_organization_github_connection to learn why and who must act; for Cloudflare, call diagnose_organization_cloudflare_connection.',
33
+ description: 'Read the Organization\'s GitHub and Cloudflare publishing connections: status, revision (needed to change or disconnect), account identity, media_ready, media migration, github_setup (app_configured, encryption_available) 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. When GitHub is not connected, call diagnose_organization_github_connection to learn why and who must act; for Cloudflare, call diagnose_organization_cloudflare_connection. cloudflare_permissions lists, per Hosting Group, the optional capabilities of its Cloudflare connection (analytics_provisioning, analytics_reports, redirect_rules) with status available, approval_required, not_offered, unknown (API token) or connection_required, the exact missing_scopes, and permission_update: one "Approve update" at connect_url that an owner or admin approves once for every connection of the Organization using that Cloudflare account. Read it before update_app (Analytics) or set_publishing_domains (redirect_hosts).',
34
34
  inputSchema: {},
35
35
  handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('publishing/connections'))),
36
36
  },
@@ -75,7 +75,7 @@ export const domainTools = [
75
75
  },
76
76
  {
77
77
  name: 'diagnose_organization_cloudflare_connection', noSite: true,
78
- description: 'Explain why a Cloudflare hosting connection (the Organization\'s Default Hosting Group, or hosting_group_id from list_hosting_groups) is or is not working and who must do what next. Returns the Organization\'s state: diagnosis.outcome (connected, needs_attention, choose, action_required, sign_in_pending, sign_in_required, retryable_error or unavailable), blockers, and the saved account once connected. A person\'s unfinished sign-in and the Cloudflare accounts they authorized stay in their own browser session. Each blocker has a code (for example oauth_cancelled, permissions_missing with missing_permissions, pages_access_denied, account_choice_expired, locked_to_account), who must act (you = the person connecting, cloudflare_account_admin, organization_admin = owner or admin of the Organization that already uses a shared account, publisher = operator of this Typeroll installation, typeroll_admin), a message and one action: a dash.cloudflare.com or documentation link, or a browser step (sign_in, retry) completed at connect_url. Read only, except recheck: true re-verifies a saved connection (authorization renewal, account access, Cloudflare Pages access, granted permissions) with its own authorization. An API key cannot sign in to Cloudflare; relay the message and link to the user. Requires an organization API key; never returns credentials.',
78
+ description: 'Explain why a Cloudflare hosting connection (the Organization\'s Default Hosting Group, or hosting_group_id from list_hosting_groups) is or is not working and who must do what next. Returns the Organization\'s state: diagnosis.outcome (connected, needs_attention, choose, action_required, sign_in_pending, sign_in_required, retryable_error or unavailable), blockers, and the saved account once connected. A person\'s unfinished sign-in and the Cloudflare accounts they authorized stay in their own browser session. Each blocker has a code (for example oauth_cancelled, permissions_missing with missing_permissions, pages_access_denied, account_choice_expired, locked_to_account), who must act (you = the person connecting, cloudflare_account_admin, organization_admin = owner or admin of the Organization that already uses a shared account, publisher = operator of this Typeroll installation, typeroll_admin), a message and one action: a dash.cloudflare.com or documentation link, or a browser step (sign_in, retry) completed at connect_url. Read only, except recheck: true re-verifies a saved connection (authorization renewal, account access, Cloudflare Pages access, granted permissions) with its own authorization. An API key cannot sign in to Cloudflare; relay the message and link to the user. `permissions` reports the connection\'s optional capabilities (analytics_provisioning, analytics_reports, redirect_rules), their missing_scopes and the single permission_update next step; approving it enables Analytics for no Site. Requires an organization API key; never returns credentials.',
79
79
  inputSchema: {
80
80
  hosting_group_id: z.string().optional().describe('Omit for the organization (Default) connection.'),
81
81
  recheck: z.boolean().optional().describe('Re-verify a saved connection before answering.'),
@@ -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. 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 a blank Site and do the planning yourself, or ask an owner or admin to enable it. 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.65';
11
+ export const VERSION = '0.45.67';
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.65",
3
+ "version": "0.45.67",
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",
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
 
@@ -115,6 +115,22 @@ shortening, adding a CTA, tweaking SEO description.
115
115
  Don't `update_page status:"published"` without an explicit "looks
116
116
  good, publish it" from the user. Same for `trigger_deploy`.
117
117
 
118
+ ## Instead of the content workflows
119
+
120
+ `start_workflow` types `content_generation` and `content_improvement` run the
121
+ model with the Organization's own AI key. On a Site without that access they
122
+ fail with `failure_code: "ai_not_enabled"`; don't retry them. Do the work with
123
+ this recipe and tell the user you did it yourself:
124
+
125
+ - **Generate a page** (`content_generation { topic, tone?, audience? }`):
126
+ steps 1–5 for that topic, created as a draft.
127
+ - **Improve content** (`content_improvement { limit? }`): `list_pages
128
+ status="published"` and `batch_read_pages`, rank the pages by thin text,
129
+ missing `seo_title`/`seo_description` and age (not updated for a year), and
130
+ rewrite the weakest few (five by default) in the site's voice. Keep facts,
131
+ URLs and structure; leave the rewrites as unsaved drafts and show previews
132
+ before anything is saved or published.
133
+
118
134
  ## SEO conventions worth knowing
119
135
 
120
136
  - **`kind: "article"`** for blog posts and news. Switches to
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tr-edit-site
3
- description: Edit or create Typeroll pages with a person’s usual AI assistant. Confirm one site, find the right page, preview changes and save with permission.
3
+ description: Edit or create Typeroll pages with a person’s usual AI assistant. Confirm one site, find the right page, handle everyday requests (page links, images, menus, site-wide changes, settings, analytics, structured data), preview changes and save with permission.
4
4
  ---
5
5
 
6
6
  # Work on one Typeroll site
@@ -33,6 +33,93 @@ description: Edit or create Typeroll pages with a person’s usual AI assistant.
33
33
  draft durable; saving and deploying are different actions. Publish only
34
34
  when authorized and readiness passes. Report the actual result and URL.
35
35
 
36
+ ## Everyday requests
37
+
38
+ Read the page or global block first, change only what was asked, and keep
39
+ everything else. When several pages fit the description, ask before editing.
40
+
41
+ **Addresses and preview links.** `list_pages` and `read_page` return each
42
+ page's `url` (its path). The public address is `get_site` → `urls.production`
43
+ plus that path, and it serves the page only after it is published and
44
+ deployed. Never invent a domain; when there is none, say why and offer a
45
+ preview instead: `get_preview_link page_id=… include_working_copy:true` shows
46
+ drafts and unpublished pages. Mint one link and reuse it while iterating.
47
+
48
+ **Images the user provides.** Upload first: a URL with `upload_media_from_url`,
49
+ a local file with `create_upload_url` (see `tr-images`); `list_media` finds
50
+ existing ones. Use the returned CDN URL exactly. With a named target, place it
51
+ there (`add_block` with `core/image` on a block page; an `<img>` with `alt` in
52
+ `html_content` on an HTML page). Without one, ask what the image is for. Ask
53
+ for alt text when none was given. Afterwards say in one sentence where it
54
+ landed; to answer "has it been added?", read the page again rather than
55
+ trusting the conversation.
56
+
57
+ **Small edits and new pages.** Block pages: `get_page_blocks`, then
58
+ `update_block` on the one block. HTML pages: `read_page`, then `update_page`
59
+ with the complete `html_content`, never a fragment. New pages: `create_page`
60
+ (draft by default) with a realistic start: heading, introduction and one
61
+ section. A Page template is `update_page` `template` (an allowed ID, or null
62
+ for the content type default). `discard_working_copy` throws an unsaved draft
63
+ away. Publishing means saving the draft, `update_page` `status: "published"`
64
+ (applies immediately) and, when the user wants it live now, `trigger_deploy`.
65
+
66
+ **Site-wide changes are composition, not a series of page edits.** "Change
67
+ every hero", "a CTA on every product page", "our phone number in every
68
+ footer":
69
+
70
+ 1. `list_partials` shows what is already shared: header, footer and free
71
+ global blocks.
72
+ 2. If a fitting global block exists, edit it once. Run
73
+ `find_pages_using_block` first and tell the user the blast radius ("this
74
+ block is on 8 pages and one template — go ahead?"); a header or footer
75
+ among the results means every page.
76
+ 3. If nothing fits, propose a global block: `make_block_global` turns an
77
+ existing block into one, or `create_free_block` with a kebab-case ID. Place
78
+ it with `core/global_block` on block pages or `<x-include name="…" />` on
79
+ HTML pages. Layout that every Page of a type shares belongs in its Page
80
+ template (`tr-page-template`).
81
+ 4. Edit pages one by one only for content that really differs per page, such
82
+ as each page's SEO description. Confirm the page list first.
83
+
84
+ "All 23 pages" or "every page" is a strong hint for a global block. Say what
85
+ you will do and wait for confirmation before editing many pages.
86
+
87
+ **Extract while you sweep.** Even when the user asked for per-page edits,
88
+ pause when the same content is about to land on a third page and offer to
89
+ make it a global block instead ("I'm adding the same CTA to several pages —
90
+ make it a reusable `newsletter-cta` block so future edits happen once?").
91
+ Check `list_partials` and `find_pages_using_block` first so you extend an
92
+ existing block rather than create a duplicate.
93
+
94
+ **Menu and footer.** `read_partial partial_id="header"`. In a block header,
95
+ change the `core/navigation` block's `links` with `update_block`; in an HTML
96
+ header, add the `<a href="/pricing">Pricing</a>` to the nav and write back the
97
+ complete HTML with `update_partial`. The footer works the same way. Building
98
+ either from scratch: `tr-header-footer`.
99
+
100
+ **Settings, not page edits.** Brand colors, fonts, logo, icons and contact
101
+ details are `update_site_settings` (admin), for example `colors.primary`. A new
102
+ logo must be uploaded first. Analytics, advertising pixels and chat widgets
103
+ are set by ID in the Integrations app (`read_app app_id="integrations"`, then
104
+ `update_app`), not pasted as scripts; turn on the cookie banner
105
+ (`update_site_settings` `cookie_consent: { enabled: true }`) so they wait for
106
+ consent. Only a tag the Integrations catalog lacks goes in
107
+ `cookie_consent.scripts_optional`. Both reach the site with the next deploy.
108
+
109
+ **Redirects, forms, listings.** A renamed URL gets `create_redirect` (301
110
+ unless the user says otherwise); changing a page's `path` creates one on save.
111
+ Place forms with a `core/form` block or `<x-form id="…" />`, never a
112
+ hand-written `<form>` (`tr-forms`). Lists of articles are a `core/page_list`
113
+ block, never hand-written HTML (`tr-blog`).
114
+
115
+ **Structured data.** For an FAQ, recipe or event page use `add_faq_schema`,
116
+ `add_recipe_schema` or `add_event_schema` instead of hand-writing `json_ld`;
117
+ see `tr-seo`.
118
+
119
+ **When something is out of reach.** If a tool refuses, tell the user what they
120
+ can do about it, not only the error. When no tool covers a request (billing,
121
+ for example), say so plainly and point to the relevant portal page.
122
+
36
123
  ## Compact discovery
37
124
 
38
125
  Find tools with `search_tools`, inspect one schema with `describe_tool`, then