@typeroll/mcp-server 0.45.82 → 0.45.84

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.
@@ -1,4 +1,5 @@
1
1
  import { readFile } from 'node:fs/promises';
2
+ import { apiUrlProblem } from './api-url.js';
2
3
  function option(args, name) {
3
4
  const index = args.indexOf(name);
4
5
  return index >= 0 ? args[index + 1] : undefined;
@@ -83,11 +84,32 @@ export function extensionMetadataUpdate(manifest, registered, manifestOrigins) {
83
84
  }
84
85
  return update;
85
86
  }
87
+ /** One id segment, by the same rule the portal applies to allowed_site_ids. */
88
+ function isIdSegment(value) {
89
+ return value.length >= 1 && value.length <= 128 && value !== '.' && value !== '..' && !/[/\\\s\x00-\x1f\x7f]/.test(value);
90
+ }
91
+ /**
92
+ * Parse `install --site`. `org/site` names a site of any organization and is
93
+ * written to allowed_site_ids in that form; a bare site id only ever matches
94
+ * a site of the developer organization itself.
95
+ */
96
+ export function parseInstallSite(value) {
97
+ const parts = value.split('/');
98
+ if (parts.length === 1 && isIdSegment(value))
99
+ return { siteId: value, ownerOrgId: null, allowEntry: value };
100
+ if (parts.length === 2 && isIdSegment(parts[0]) && isIdSegment(parts[1])) {
101
+ return { siteId: parts[1], ownerOrgId: parts[0], allowEntry: value };
102
+ }
103
+ throw new Error('--site must be <site-id> or <organization-id>/<site-id>');
104
+ }
86
105
  async function api(path, init = {}, allowed = []) {
87
106
  const baseUrl = process.env.TYPEROLL_API_URL?.trim().replace(/\/$/, '');
88
107
  const apiKey = process.env.TYPEROLL_API_KEY?.trim();
89
108
  if (!baseUrl || !apiKey)
90
109
  throw new Error('TYPEROLL_API_URL and TYPEROLL_API_KEY are required');
110
+ const apiUrlError = apiUrlProblem(baseUrl);
111
+ if (apiUrlError)
112
+ throw new Error(apiUrlError);
91
113
  const headers = new Headers(init.headers);
92
114
  headers.set('Authorization', `Bearer ${apiKey}`);
93
115
  if (init.body)
@@ -102,7 +124,7 @@ function help() {
102
124
  console.error('Usage:');
103
125
  console.error(' typeroll extension validate [manifest]');
104
126
  console.error(' typeroll extension push --draft [--manifest path]');
105
- console.error(' typeroll extension install --site <site-id> [--manifest path] [--config config.json]');
127
+ console.error(' typeroll extension install --site <site-id | organization-id/site-id> [--manifest path] [--config config.json]');
106
128
  console.error(' typeroll extension configure --site <site-id> --installation <installation-id> --config config.json [--no-deploy]');
107
129
  console.error(' typeroll extension promote <version> [--manifest path]');
108
130
  }
@@ -193,24 +215,32 @@ export async function runExtensionCli(args) {
193
215
  return 0;
194
216
  }
195
217
  if (command === 'install') {
196
- const siteId = option(args, '--site');
197
- if (!siteId)
198
- throw new Error('install requires --site <site-id>');
218
+ const siteOption = option(args, '--site');
219
+ if (!siteOption)
220
+ throw new Error('install requires --site <site-id> or --site <organization-id>/<site-id>');
221
+ const site = parseInstallSite(siteOption);
199
222
  const configPath = option(args, '--config');
200
223
  const config = configPath ? await readJson(configPath) : {};
201
224
  const permissions = Array.isArray(manifest.permissions) ? manifest.permissions : [];
202
225
  if (manifest.distribution === 'private') {
226
+ if (!site.ownerOrgId) {
227
+ console.error(`Note: allowed_site_ids entry "${site.allowEntry}" only allows a site of your developer organization. `
228
+ + 'To allow a site of another organization, pass --site <organization-id>/<site-id>.');
229
+ }
203
230
  const current = await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}`);
204
231
  const registered = current.data.extension;
205
232
  const allowed = Array.isArray(registered?.allowed_site_ids) ? registered.allowed_site_ids.map(String) : [];
206
- if (!allowed.includes(siteId)) {
233
+ if (!allowed.includes(site.allowEntry)) {
207
234
  await api(`/api/developer/extensions/${encodeURIComponent(extensionId)}`, {
208
- method: 'PATCH', body: JSON.stringify({ allowed_site_ids: [...allowed, siteId] }),
235
+ method: 'PATCH', body: JSON.stringify({ allowed_site_ids: [...allowed, site.allowEntry] }),
209
236
  });
210
237
  }
211
238
  }
212
- const installed = await api(`/api/v1/sites/${encodeURIComponent(siteId)}/extensions`, {
239
+ // With `org/site`, declare the owner so the server refuses to resolve the
240
+ // id to a different organization's site of the same name.
241
+ const installed = await api(`/api/v1/sites/${encodeURIComponent(site.siteId)}/extensions`, {
213
242
  method: 'POST',
243
+ headers: site.ownerOrgId ? { 'Typeroll-Organization': site.ownerOrgId } : undefined,
214
244
  body: JSON.stringify({
215
245
  extension_id: extensionId,
216
246
  version: manifest.version,
package/dist/index.js CHANGED
@@ -20,6 +20,7 @@ import { resolveSiteId } from './resolve-site-id.js';
20
20
  import { buildServer } from './server.js';
21
21
  import { VERSION } from './version.js';
22
22
  import { runExtensionCli } from './extension-cli.js';
23
+ import { apiUrlProblem } from './api-url.js';
23
24
  import { doctor, readWorkspace, workspaceClient, verifyWorkspaceBinding } from './workspace.js';
24
25
  function bail(message) {
25
26
  console.error(`typeroll-mcp: ${message}`);
@@ -85,6 +86,9 @@ async function main() {
85
86
  const apiKey = process.env.TYPEROLL_API_KEY?.trim();
86
87
  if (!apiUrl)
87
88
  bail('TYPEROLL_API_URL is not set. Point it at your Typeroll portal (e.g. https://app.typeroll.com).');
89
+ const apiUrlError = apiUrlProblem(apiUrl);
90
+ if (apiUrlError)
91
+ bail(apiUrlError);
88
92
  if (!apiKey)
89
93
  bail('TYPEROLL_API_KEY is not set. Create a key in /app/sites/{siteId}/settings/api-keys and copy it once.');
90
94
  if (!apiKey.startsWith('typeroll_live_')) {
package/dist/server.js CHANGED
@@ -97,6 +97,7 @@ function effectFor(name) {
97
97
  || name === 'revoke_site_share'
98
98
  || name === 'revoke_organization_api_key'
99
99
  || name === 'create_organization_invite'
100
+ || name === 'revoke_organization_invite'
100
101
  || name === 'set_organization_ai_key'
101
102
  || name === 'remove_organization_ai_key'
102
103
  || name === 'set_organization_ai_site_access'
@@ -6,10 +6,11 @@
6
6
  import { z } from 'zod';
7
7
  import { ok, withErrorBoundary } from './helpers.js';
8
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.');
9
+ const inviteId = z.string().regex(/^[A-Za-z0-9_-]{16,64}$/).describe('Invite id from create_organization_invite or list_organization_invites.');
9
10
  export const accessTools = [
10
11
  {
11
12
  name: 'list_api_keys',
12
- description: 'List this Site\'s site-scoped API keys: id (prefix), name, created_at/created_by, last_used_at/last_used_ip and revoked_at. Never returns secrets or hashes. Any key that reaches the Site may list, as in Site settings → API keys.',
13
+ description: 'List this Site\'s site-scoped API keys: id (prefix), name, created_at/created_by, last_used_at/last_used_ip and revoked_at. Never returns secrets or hashes. Requires admin on the Site, as in Site settings → API keys.',
13
14
  inputSchema: {},
14
15
  handler: withErrorBoundary(async (_args, { client, siteId }) => ok(await client.get(siteId, 'api-keys'))),
15
16
  },
@@ -69,10 +70,28 @@ export const accessTools = [
69
70
  {
70
71
  name: 'create_organization_invite',
71
72
  noSite: true,
72
- description: 'Create a signed invite link to the Organization (organization API key required). The invited person opens invite_url, signs in and joins as an editor; the link works until expires_at (ttl_days 1–30, default 7) and cannot be revoked early. Send it only to the person the user named.',
73
- inputSchema: { ttl_days: z.number().int().min(1).max(30).optional() },
73
+ description: 'Create a signed invite link to the Organization (organization API key required). The invited person opens invite_url, signs in and joins as an editor. The link works until expires_at (ttl_days 1–30, default 7) for max_uses people (1–50, default 1: single use); with email set, only a person signed in with that address can redeem it. Returns the invite id; list_organization_invites shows its state and revoke_organization_invite (or DELETE /api/v1/organization/invites/{id}) stops the link immediately. Send the link only to the person the user named.',
74
+ inputSchema: {
75
+ ttl_days: z.number().int().min(1).max(30).optional(),
76
+ max_uses: z.number().int().min(1).max(50).optional().describe('How many people may redeem the link. Default 1 (single use).'),
77
+ email: z.string().email().max(320).optional().describe('Bind the invite to this email address: only a person signed in with it can redeem the link.'),
78
+ },
74
79
  handler: withErrorBoundary(async (args, { client }) => ok(await client.rootPost('organization/invites', args))),
75
80
  },
81
+ {
82
+ name: 'list_organization_invites',
83
+ noSite: true,
84
+ description: 'List the Organization\'s invites, newest first, without their links: id, state (active | used | expired | revoked), role, email (when bound), created_by, created_at, expires_at, max_uses, used_count and revoked_at. Requires an organization API key; site-scoped keys get 403.',
85
+ inputSchema: {},
86
+ handler: withErrorBoundary(async (_args, { client }) => ok(await client.rootGet('organization/invites'))),
87
+ },
88
+ {
89
+ name: 'revoke_organization_invite',
90
+ noSite: true,
91
+ description: 'Revoke an Organization invite by id (from create_organization_invite or list_organization_invites). Its link stops working immediately; people who already joined keep their membership. Requires an organization API key.',
92
+ inputSchema: { invite_id: inviteId },
93
+ handler: withErrorBoundary(async (args, { client }) => ok(await client.rootDelete(`organization/invites/${encodeURIComponent(args.invite_id)}`))),
94
+ },
76
95
  {
77
96
  name: 'read_organization_ai_settings',
78
97
  noSite: true,
@@ -41,7 +41,7 @@ export const deployTools = [
41
41
  },
42
42
  {
43
43
  name: 'get_deploy_status',
44
- description: "Status of a single deploy job. Optional top-level build_progress reports the exact task status, attempt, step and completed/total files; not an overall percentage or ETA. Returns { job, queued_for_seconds, queue_timeout_seconds }. job.status is one of queued | running | succeeded | failed. On a failed build, job.error names the failed step and the cause the step printed, job.failure.code is the reported code (e.g. media_build_process_exit_1) and job.failure.diagnostic holds { cause, lines } (the last 40 lines that step printed, credentials removed). On the Cloudflare build engine job.failure.provider_log also holds { step, build_id, summary, lines } from the provider's build log (last 60 lines). Read them before retrying; retrying does not fix a cause that is in the content or the build engine. Polling cadence: ~5s for queued/running. job.render_report, when present, reports actual HTML mode (full/partial), rendered, reused, total and removed route counts plus a fallback reason. From Core 0.2.17, custom blocks and consumed image metadata are tracked per route; older caches require one full build. It does not measure media or provider startup savings. job.coordinator, when present, reports the last completed CMS phase and duration_ms for that attempt; external build waiting is excluded. Keep observing the same job across checkpoints; do not trigger duplicate deploys. A job stuck in 'queued' for longer than queue_timeout_seconds (default 300s) auto-flips to failed with phase='queue_timeout' — so a single poll past that timestamp returns the terminal state and you can stop polling.",
44
+ description: "Status of a single deploy job. Optional top-level build_progress reports the exact task status, attempt, step and completed/total files; not an overall percentage or ETA. Returns { job, queued_for_seconds, queue_timeout_seconds }. job.status is one of queued | running | succeeded | failed. On a failed build, job.error names the failed step and the cause the step printed, job.failure.code is the reported code (e.g. media_build_process_exit_1) and job.failure.diagnostic holds { cause, lines } (the last 40 lines that step printed, credentials removed). On the Cloudflare build engine job.failure.provider_log also holds { step, build_id, summary, lines } from the provider's build log (last 60 lines). Read them before retrying; retrying does not fix a cause that is in the content or the build engine. job.warnings, when present, lists non-blocking findings of a deploy that still ran, for example a media file left out of the publication because it could not be published safely (an SVG that could not be sanitized); report them to the user. Polling cadence: ~5s for queued/running. job.render_report, when present, reports actual HTML mode (full/partial), rendered, reused, total and removed route counts plus a fallback reason. From Core 0.2.17, custom blocks and consumed image metadata are tracked per route; older caches require one full build. It does not measure media or provider startup savings. job.coordinator, when present, reports the last completed CMS phase and duration_ms for that attempt; external build waiting is excluded. Keep observing the same job across checkpoints; do not trigger duplicate deploys. A job stuck in 'queued' for longer than queue_timeout_seconds (default 300s) auto-flips to failed with phase='queue_timeout' — so a single poll past that timestamp returns the terminal state and you can stop polling.",
45
45
  inputSchema: { job_id: z.string(), include_provider: z.boolean().optional().describe('Also read the exact Git commit and Cloudflare build stages for a customer Git publication. Does not return build secrets.') },
46
46
  handler: withErrorBoundary(async (args, { client, siteId }) => {
47
47
  const res = await client.get(siteId, `deploys/${encodeURIComponent(args.job_id)}`);
@@ -142,21 +142,21 @@ export const developerExtensionTools = [
142
142
  },
143
143
  {
144
144
  name: 'read_developer_extension', noSite: true,
145
- description: 'Read one of your organization\'s Extensions with all saved releases and their status (draft, review, published, deprecated, revoked). Requires an organization API key.',
145
+ description: 'Read one of your organization\'s Extensions with all saved releases and their status (draft, review, published, deprecated, revoked). extension.developer_org_id is the value of the developer_org_id claim in every token Typeroll issues for its installations: provider backends must require it in addition to aud. id_reservation is reserved, unreserved (a registration that predates reservations; any update claims it) or held_by_other (another developer organization holds this id). Requires an organization API key.',
146
146
  inputSchema: { extension_id: z.string().min(1) },
147
147
  handler: withErrorBoundary(async (args, { client }) => ok(await client.developer('GET', ext(args.extension_id)))),
148
148
  },
149
149
  {
150
150
  name: 'update_developer_extension', noSite: true,
151
- description: 'Update an Extension\'s name, status (active/suspended), distribution (only while every release is a draft), trusted origins or private allow-lists. Requires an organization API key.',
151
+ description: 'Update an Extension\'s name, status (active/suspended), distribution (only while every release is a draft), trusted origins or private allow-lists. Each list replaces the saved one: allowed_org_ids allows every Site of those Organizations; allowed_site_ids entries are "<organization-id>/<site-id>" for a Site of another Organization, while a bare site id only matches a Site of your own developer Organization. Requires an organization API key.',
152
152
  inputSchema: {
153
153
  extension_id: z.string().min(1),
154
154
  name: z.string().optional(),
155
155
  status: z.enum(['active', 'suspended']).optional(),
156
156
  distribution: z.enum(['private', 'unlisted', 'public']).optional(),
157
157
  trusted_origins: z.array(z.string()).optional(),
158
- allowed_org_ids: z.array(z.string()).optional(),
159
- allowed_site_ids: z.array(z.string()).optional(),
158
+ allowed_org_ids: z.array(z.string()).optional().describe('Organization ids whose every Site may install this private Extension. Replaces the saved list.'),
159
+ allowed_site_ids: z.array(z.string().max(257)).max(500).optional().describe('Sites that may install this private Extension, replacing the saved list: "<organization-id>/<site-id>" for another Organization\'s Site, or a bare site id for a Site of your own developer Organization.'),
160
160
  },
161
161
  handler: withErrorBoundary(async (args, { client }) => {
162
162
  const { extension_id, ...patch } = args;
@@ -149,7 +149,7 @@ export const formTools = [
149
149
  },
150
150
  {
151
151
  name: 'list_form_submissions',
152
- description: 'List submissions received for a form, newest first, cursor-paginated (cap 200 per page). Admins also see each submission\'s webhook delivery status. Use this to help a customer triage inbound contact / lead submissions; read_form_submission reads one.',
152
+ description: 'List submissions received for a form, newest first, cursor-paginated (cap 200 per page). Admins also see each submission\'s webhook delivery status. Visitor IP addresses are not stored or returned; completed submissions past the site\'s form_submission_retention_days (read_site_settings) are deleted and never listed. Use this to help a customer triage inbound contact / lead submissions; read_form_submission reads one.',
153
153
  inputSchema: {
154
154
  form_id: z.string(),
155
155
  limit: z.number().int().min(1).max(200).optional(),
@@ -163,7 +163,7 @@ export const formTools = [
163
163
  },
164
164
  {
165
165
  name: 'read_form_submission',
166
- description: 'Read one submission of a form by id (from list_form_submissions): the submitted values, status (partial or complete), step and timestamps. Admins also see its webhook delivery status, as in the portal.',
166
+ description: 'Read one submission of a form by id (from list_form_submissions): the submitted values, status (partial or complete), step and timestamps, with expires_at when the submission is due for deletion. Admins also see its webhook delivery status, as in the portal.',
167
167
  inputSchema: {
168
168
  form_id: z.string(),
169
169
  submission_id: z.string(),
@@ -67,8 +67,8 @@ export const mediaTools = [
67
67
  name: 'create_upload_url',
68
68
  description: 'Mint a 5-min signed PUT URL for direct-to-R2 upload. After the upload completes, the CDN url is what you reference in <img src="…">. REQUIRED follow-up: call `finalize_media` after the PUT succeeds — without it the original ships with no Cache-Control (Lighthouse will flag it) and no responsive variants get generated. The response carries `finalize_url` as a reminder. This signed-PUT path is the INTEGRITY-SAFE way to upload bytes from disk or a generated asset — a direct `curl --data-binary @file` sends the bytes straight to R2 without transcribing them through the model, so it can\'t be corrupted the way a large `upload_media_inline` base64 blob can (ESPECIALLY use this, not inline, for SVG/logos). For a public source URL, `upload_media_from_url` is the one-call equivalent; reserve `upload_media_inline` for tiny payloads only.',
69
69
  inputSchema: {
70
- filename: z.string().min(1),
71
- content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
70
+ filename: z.string().min(1).describe('Should end in an extension of content_type (e.g. logo.png with image/png). When the extension is missing or belongs to another type, the type\'s extension is appended (logo → logo.png, photo.html → photo.html.png); the response returns the stored filename. Unsupported types answer 415.'),
71
+ content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf". SVGs are sanitized at finalize; an SVG that cannot be sanitized is refused.'),
72
72
  size: z.number().int().nonnegative().optional(),
73
73
  alt_text: z.string().optional(),
74
74
  },
@@ -82,7 +82,7 @@ export const mediaTools = [
82
82
  description: 'Fetch an image (or PDF) from any public URL and push it to the site\'s media library in one call. The MCP server downloads the source from verified public HTTP(S) destinations; private addresses and unsafe redirects are refused. Integrity-safe: the bytes are streamed, never transcribed as base64 through the model, so (unlike a large `upload_media_inline` payload) they can\'t be silently corrupted in transit. Returns { media_id, cdn_url, filename }.',
83
83
  inputSchema: {
84
84
  source_url: z.string().url().describe('Public URL to download the image from.'),
85
- filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url.'),
85
+ filename: z.string().optional().describe('Override the filename used on R2. Defaults to the last path segment of source_url. When its extension does not match the content type, the type\'s extension is appended; the response returns the stored filename.'),
86
86
  content_type: z.string().optional().describe('Override the inferred content type (e.g. when source_url has no extension).'),
87
87
  alt_text: z.string().optional(),
88
88
  },
@@ -131,7 +131,7 @@ export const mediaTools = [
131
131
  name: 'upload_media_inline',
132
132
  description: 'Upload an image (or PDF) from base64-encoded bytes. ⚠️ Use ONLY for tiny payloads (a few KB). The base64 string crosses the model/tool boundary as text, where a larger blob can be SILENTLY CORRUPTED in transit — a few mutated chars yield a broken-but-valid-looking file that uploads with no error and the right size, and only fails when rendered (this has bitten a ~12KB logo SVG: half the wordmark vanished). For anything non-trivial, and ALWAYS for SVG/logos or assets you generated, prefer create_upload_url + a direct curl --data-binary PUT of the file bytes (bytes never transcribed → verified byte-identical), or upload_media_from_url (bytes fetched from a URL). After uploading any generated asset, VERIFY it (render it, or byte-diff against the source) before referencing it. Returns { media_id, cdn_url, filename }.',
133
133
  inputSchema: {
134
- filename: z.string().min(1),
134
+ filename: z.string().min(1).describe('When its extension does not match content_type, the type\'s extension is appended; the response returns the stored filename.'),
135
135
  content_type: z.string().min(1).describe('e.g. "image/png", "image/jpeg", "application/pdf"'),
136
136
  data_base64: z.string().min(1).describe('The file bytes, base64-encoded (without a data: URL prefix).'),
137
137
  alt_text: z.string().optional(),
@@ -172,7 +172,7 @@ export const mediaTools = [
172
172
  return ok({
173
173
  media_id: mint.media_id,
174
174
  cdn_url: mint.cdn_url,
175
- filename: args.filename,
175
+ filename: mint.filename ?? args.filename,
176
176
  content_type: args.content_type,
177
177
  size_bytes: buf.byteLength,
178
178
  finalize: finalizeResult,
@@ -182,7 +182,7 @@ export const mediaTools = [
182
182
  },
183
183
  {
184
184
  name: 'update_media',
185
- description: 'Patch a media item\'s alt text, filename or public path. A public path change is applied at the next publication.',
185
+ description: 'Patch a media item\'s alt text, filename or public path. A rename must be correct: a new filename or public path must end in an extension matching the file\'s type, and nothing is appended (400 media_extension_mismatch otherwise). A public path change is applied at the next publication.',
186
186
  inputSchema: {
187
187
  media_id: z.string(),
188
188
  alt_text: z.string().optional(),
@@ -28,7 +28,7 @@ export const settingsTools = [
28
28
  },
29
29
  {
30
30
  name: 'read_site_settings',
31
- description: "Read every site setting: name, tagline, logo, favicon/app icons, colors, fonts, contact info, social links, URL trailing-slash policy, iframe host allowlist, default SEO suffix/description, default_og_image, twitter_handle, organization (JSON-LD), language, robots_txt, image_sizes_default, cookie_consent, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css, `urls` (including `urls.staging`, the site's staging_url) and `render` (the site's platform render version, the latest version and what upgrading would change). Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
31
+ description: "Read every site setting: name, tagline, logo, favicon/app icons, colors, fonts, contact info, social links, URL trailing-slash policy, iframe host allowlist, default SEO suffix/description, default_og_image, twitter_handle, organization (JSON-LD), language, robots_txt, image_sizes_default, cookie_consent, plus the scriptable surfaces scripts_head, scripts_body_end, and custom_css, form_submission_retention_days (days completed form submissions are kept before deletion; null = kept until deleted; site-wide, not per version), `urls` (including `urls.staging`, the site's staging_url) and `render` (the site's platform render version, the latest version and what upgrading would change). Pass `version` to read a branch's settings (with copy-on-write chain-fallback to main for fields the branch hasn't overridden).",
32
32
  inputSchema: {
33
33
  version: versionParam,
34
34
  },
@@ -47,6 +47,7 @@ export const settingsTools = [
47
47
  'scripts_head / scripts_body_end / custom_css and cookie_consent scripts ARE writable here — useful for a global stylesheet or analytics tag across all pages. ' +
48
48
  'Social sharing and structured data: default_og_image, twitter_handle, organization ({name, logo, same_as[]}, replaced as a whole; null clears). ' +
49
49
  'staging_url is stored on the Site (not per version), so it applies whichever `version` you pass. ' +
50
+ 'form_submission_retention_days is also site-wide and deletes data: set it only when the user asks for a retention period. ' +
50
51
  'Pass `version` to scope the write to a branch (copy-on-write) instead of main — the right way to brand/recolor a site inside a redesign branch (colors, fonts, logo, custom_css) without touching the live settings. Omit it to write main.',
51
52
  inputSchema: {
52
53
  version: versionParam,
@@ -81,6 +82,7 @@ export const settingsTools = [
81
82
  same_as: z.array(z.string()).max(50).optional().describe('Profile URLs (LinkedIn, X, Facebook, …) emitted as sameAs.'),
82
83
  }).strict().nullable().optional().describe('Organization schema.org JSON-LD emitted on every page. Replaces the stored value as a whole; null (or all-empty) clears it.'),
83
84
  staging_url: z.string().nullable().optional().describe('URL of a staging environment for this site, reported as urls.staging. Stored on the Site, not per version. Trailing slashes are dropped; empty string or null clears.'),
85
+ form_submission_retention_days: z.number().int().min(30).max(3650).nullable().optional().describe('Opt-in deletion of completed form submissions this many days (30–3650) after they were completed, on every form of the site, including existing submissions: those already older are deleted immediately and cannot be restored. null (the default) keeps submissions until someone deletes them. Site administrators only (never an Extension credential); stored on the Site, not per version. The response reports form_submission_retention { updated, deleted }. Partial submissions keep their form\'s partial_ttl_days.'),
84
86
  render_version: z.number().int().min(1).optional().describe('Platform render version. Platform output changes ship as new versions so existing sites never change look on their own. Upgrade only after previewing (get_preview_link render_version) and with the user\'s approval; the tr-upgrade-rendering skill compares every page. Lowering it returns to an earlier version.'),
85
87
  design_scales: z.record(z.string(), z.record(z.string(), z.string())).optional().describe('Site token scales (replaced as a whole): type_size xs/sm/md/lg/xl/2xl/3xl; space none/xs/sm/md/lg/xl in px/rem/em; leading tight/snug/normal/relaxed/loose as unitless 1–2. Omitted steps use platform defaults.'),
86
88
  colors: z.record(z.string(), z.string().nullable()).optional().describe('Palette: primary, secondary, accent, background, surface, surface_hover, text, text_light (shallow-merged). Optional on_primary, on_secondary, on_accent set the text colour on that theme colour (buttons, badges, tabs, call-to-action sections, styles using the on_* tokens) as a hex colour such as "#ffffff"; null returns to the automatic black or white (chosen by APCA from render version 8, by the WCAG 2 ratio before). A chosen on_* colour applies on every render version. The response lists `on_colors` with each one\'s APCA Lc and WCAG 2 ratio; a choice below APCA Lc 60 (button text, 16px/600) is kept and returned as a warning. Prefer the automatic choice unless the brand specifies a colour.'),
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.82';
11
+ export const VERSION = '0.45.84';
package/dist/workspace.js CHANGED
@@ -5,12 +5,12 @@ import { z } from 'zod';
5
5
  import { VERSION } from './version.js';
6
6
  import { BUNDLED_WORKSPACE_TEMPLATES } from './bundled-content.js';
7
7
  import { ApiError, TyperollClient } from './client.js';
8
+ import { isSecureApiUrl } from './api-url.js';
8
9
  export const workspaceSchema = z.object({
9
10
  schema_version: z.literal(1),
10
11
  portal: z.string().url().refine(value => {
11
12
  const url = new URL(value);
12
- return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' &&
13
- (url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname)));
13
+ return !url.username && !url.password && !url.search && !url.hash && url.pathname === '/' && isSecureApiUrl(url);
14
14
  }, 'Use an HTTPS portal origin (HTTP is allowed only on loopback).'),
15
15
  organization_id: z.string().min(1).nullable(), site_id: z.string().min(1).nullable(),
16
16
  version: z.string().min(1), tool_mode: z.enum(['compact', 'full']),
@@ -186,11 +186,32 @@ export async function writeWorkspaceFiles(dir, templates, update) {
186
186
  await fs.writeFile(lockFile, JSON.stringify({ schema_version: 1, generator_version: VERSION, files }, null, 2) + '\n');
187
187
  return results;
188
188
  }
189
+ /** The hosted Typeroll Cloud portal: the only origin trusted from typeroll.json alone. */
190
+ export const OFFICIAL_PORTAL_ORIGIN = 'https://app.typeroll.com';
191
+ /** Raised before any credential is sent when the committed portal is not confirmed by the environment. */
192
+ export class UntrustedPortalError extends Error {
193
+ }
194
+ /**
195
+ * typeroll.json is committed and can be changed by anyone who can open a pull
196
+ * request, so its portal alone never receives the injected API key. Another
197
+ * origin must be confirmed through the private TYPEROLL_API_URL setting.
198
+ */
199
+ export function assertTrustedPortal(config, env) {
200
+ const portal = config.portal.replace(/\/$/, '');
201
+ const confirmed = env.TYPEROLL_API_URL?.trim().replace(/\/$/, '');
202
+ if (confirmed) {
203
+ if (confirmed !== portal)
204
+ throw new Error('TYPEROLL_API_URL conflicts with typeroll.json.');
205
+ return;
206
+ }
207
+ if (portal !== OFFICIAL_PORTAL_ORIGIN) {
208
+ throw new UntrustedPortalError(`typeroll.json names the portal ${portal}. The API key is sent only to ${OFFICIAL_PORTAL_ORIGIN} unless you confirm another portal: set TYPEROLL_API_URL=${portal} in your private client settings or environment.`);
209
+ }
210
+ }
189
211
  export function workspaceClient(config, env, fetchImpl) {
190
212
  if (!env.TYPEROLL_API_KEY)
191
213
  throw new Error('Inject TYPEROLL_API_KEY through private client settings or the environment.');
192
- if (env.TYPEROLL_API_URL && env.TYPEROLL_API_URL.replace(/\/$/, '') !== config.portal.replace(/\/$/, ''))
193
- throw new Error('TYPEROLL_API_URL conflicts with typeroll.json.');
214
+ assertTrustedPortal(config, env);
194
215
  if (env.TYPEROLL_SITE_ID && env.TYPEROLL_SITE_ID !== config.site_id)
195
216
  throw new Error('TYPEROLL_SITE_ID conflicts with typeroll.json.');
196
217
  return new TyperollClient({ baseUrl: config.portal, apiKey: env.TYPEROLL_API_KEY, defaultVersion: config.version, fetchImpl });
@@ -229,7 +250,7 @@ export async function doctor(dir, offline, env = process.env, fetchImpl) {
229
250
  checks.push({ check: 'writes_and_publishing', status: 'not_checked', message: 'Read-only doctor. No edits, media transfer, app activation or publication was attempted; check publishing/import readiness for those tasks.' });
230
251
  }
231
252
  catch (error) {
232
- checks.push({ check: 'connection', status: 'required', message: error instanceof ApiError ? `Portal HTTP ${error.status}. Check credential scope, portal and Site/Version.` : 'Could not verify the connection. Check injected credentials, matching environment, network and Site/Version.' });
253
+ checks.push({ check: 'connection', status: 'required', message: error instanceof ApiError ? `Portal HTTP ${error.status}. Check credential scope, portal and Site/Version.` : error instanceof UntrustedPortalError ? error.message : 'Could not verify the connection. Check injected credentials, matching environment, network and Site/Version.' });
233
254
  }
234
255
  }
235
256
  return { passed: !checks.some(check => check.status === 'required'), generator_version: VERSION, checks };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.45.82",
3
+ "version": "0.45.84",
4
4
  "description": "Typeroll CMS MCP server \u2013 connect AI agents to the REST API to manage content and publish static websites.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -13,7 +13,8 @@ npx playwright install chromium
13
13
  Run the commands below from the **workspace root**. Inject TYPEROLL_API_KEY through
14
14
  your private credential manager. Never put it in a file or command argument.
15
15
  trlib.py reads portal, organization_id, site_id and version from typeroll.json,
16
- checks the connected Site/Organization, discovers the live tool schema, and
16
+ sends the key only to https://app.typeroll.com or to a portal you confirm by
17
+ setting TYPEROLL_API_URL to the same origin privately, checks the connected Site/Organization, discovers the live tool schema, and
17
18
  passes the Version where supported. It refuses unversioned operations on a
18
19
  non-main binding; use the explicit interactive API workflow for organization or
19
20
  shared media operations instead. It is a small hosted MCP helper for API keys,
@@ -31,6 +31,28 @@ def workspace():
31
31
  raise ValueError('Run from a workspace containing typeroll.json')
32
32
 
33
33
 
34
+ OFFICIAL_PORTAL = 'https://app.typeroll.com'
35
+
36
+
37
+ def trusted_portal(config):
38
+ """Return the portal origin that may receive the API key.
39
+
40
+ typeroll.json is committed, so its portal alone is trusted only when it is
41
+ the hosted portal. Another origin must be confirmed by TYPEROLL_API_URL.
42
+ """
43
+ portal = config['portal'].rstrip('/')
44
+ confirmed = (os.environ.get('TYPEROLL_API_URL') or '').strip().rstrip('/')
45
+ if confirmed:
46
+ if confirmed != portal:
47
+ raise ValueError('TYPEROLL_API_URL conflicts with typeroll.json')
48
+ return portal
49
+ if portal != OFFICIAL_PORTAL:
50
+ raise ValueError(f'typeroll.json names the portal {portal}. The API key is sent only to '
51
+ f'{OFFICIAL_PORTAL} unless you confirm another portal: set '
52
+ f'TYPEROLL_API_URL={portal} in your private environment')
53
+ return portal
54
+
55
+
34
56
  class Client:
35
57
  def __init__(self, config=None):
36
58
  self.config = config or workspace()
@@ -51,6 +73,8 @@ class Client:
51
73
  key = os.environ.get('TYPEROLL_API_KEY')
52
74
  if not key:
53
75
  raise ValueError('Inject TYPEROLL_API_KEY privately before running')
76
+ if trusted_portal(self.config) + '/api/mcp?tools=full' != self.url:
77
+ raise ValueError('Workspace portal changed after the client was created')
54
78
  headers = {'Authorization': 'Bearer ' + key, 'Content-Type': 'application/json',
55
79
  'Accept': 'application/json, text/event-stream'}
56
80
  if self.session: