@typeroll/mcp-server 0.45.81 → 0.45.83

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.
@@ -17,7 +17,7 @@ export function compactTools(catalog) {
17
17
  }),
18
18
  local('describe_tool', 'Read one tool’s complete description, effect and JSON input schema before calling it.', { name: z.string().max(100) }, async (args) => {
19
19
  const tool = byName.get(String(args.name));
20
- return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).meta({ additionalProperties: false }), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
20
+ return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: z.toJSONSchema(z.object(tool.schema).strict(), { target: 'draft-7', io: 'input' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
21
21
  }),
22
22
  ...['read', 'write', 'admin'].map(effect => ({
23
23
  name: `call_${effect}_tool`, effect,
@@ -28,7 +28,7 @@ export function compactTools(catalog) {
28
28
  if (!tool || tool.effect !== effect)
29
29
  return fail(new Error(`This endpoint accepts only ${effect} tools. Use describe_tool to check the required effect.`));
30
30
  try {
31
- return await tool.invoke(z.object(tool.schema).parse(args.arguments));
31
+ return await tool.invoke(z.object(tool.schema).strict().parse(args.arguments));
32
32
  }
33
33
  catch (error) {
34
34
  return fail(error);
@@ -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'
@@ -327,7 +328,7 @@ export function buildServer(options) {
327
328
  }
328
329
  for (const tool of options.toolMode === 'compact' ? compactTools(catalog) : catalog) {
329
330
  register(tool.name, {
330
- description: tool.description, inputSchema: z.object(tool.schema).meta({ additionalProperties: false }),
331
+ description: tool.description, inputSchema: z.object(tool.schema).strict(),
331
332
  annotations: { readOnlyHint: tool.effect === 'read', destructiveHint: tool.effect !== 'read', openWorldHint: true },
332
333
  }, tool.invoke);
333
334
  }
@@ -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,
@@ -148,15 +148,15 @@ export const developerExtensionTools = [
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;
@@ -40,7 +40,7 @@ export const settingsTools = [
40
40
  {
41
41
  name: 'update_site_settings',
42
42
  description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Accepts everything the portal Settings form does. Requires admin permission on the site, as in the portal. Only the fields you pass change. ' +
43
- 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
43
+ 'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" or "patch" key. ' +
44
44
  'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
45
45
  'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
46
46
  'Unknown top-level keys return a 400 error listing the valid fields. ' +
@@ -118,6 +118,8 @@ export const settingsTools = [
118
118
  },
119
119
  handler: withErrorBoundary(async (args, { client, siteId }) => {
120
120
  const { version, ...body } = args;
121
+ if (!Object.keys(body).some(key => body[key] !== undefined))
122
+ throw new Error('No settings supplied. Pass at least one setting at the top level, for example {tagline: "New tagline"}; do not wrap fields in patch or settings.');
121
123
  const res = await client.patch(siteId, 'settings', body, v(version));
122
124
  return ok(res);
123
125
  }),
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.81';
11
+ export const VERSION = '0.45.83';
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']),
@@ -51,6 +51,8 @@ Public documentation: https://typeroll.com/docs/llms.txt
51
51
  ## Edit and verify
52
52
 
53
53
  - Read current Pages, Content types, templates, partials and relevant block schemas.
54
+ - Read pages/ notes before editing; follow workflows/page-notes.md for sources,
55
+ dated logs, external-link review and archiving. Keep internal notes out of public output.
54
56
  - Use native editable blocks; document necessary exceptions and design decisions.
55
57
  - Configure palette/font roles and design scales in site settings. Read workflows/design.md
56
58
  before styling blocks; run lint_design_tokens and document hardcoded exceptions.
@@ -115,7 +117,7 @@ export const workspaceTemplates = {
115
117
  'brief/content-model.md': '# Content model\n\n## Page types and routes\n\n## Fields and references\n\n## Shared templates and partials\n\n## Taxonomy and navigation\n\nUse the current CMS schema. This document describes decisions, not a second schema store.\n',
116
118
  'brief/rules.md': "# Owner preferences and project rules\n\n## Confirmed rules\n\nRecord each rule, its scope, who approved it and the date. Separate confirmed rules from proposals.\n\n## Writing and factual accuracy\n\nVoice, terminology, prohibited claims, source requirements and legal copy that must not be rewritten.\n\n## Design and editing\n\nBrand constraints, accessibility, editable content, standard/custom blocks and documented HTML exceptions.\n\n## Operations and approval\n\nWho may approve content, publication, DNS changes and messages? Credentials stay outside this repository.\n\n## Exceptions and superseded rules\n\nLink decisions.md; do not silently override a confirmed rule. Project notes do not grant permissions beyond the user's authorization or account access.\n",
117
119
  'brief/seo.md': "# SEO strategy\n\n## Business goals, audience, markets and languages\n\n## Search intent and topic coverage\n\nRecord research sources and dates. Distinguish evidence from assumptions; do not invent traffic or ranking forecasts.\n\n## Page roles and URL plan\n\nContent types, useful landing pages, taxonomy, internal links and routes to preserve or redirect. Avoid near-duplicate pages created only for keyword variants.\n\n## Indexing and canonical policy\n\nWhich pages should be indexable, public noindex, or private? Record deliberate canonical overrides and sitemap policy; actual values live in the CMS.\n\n## Editorial standards and structured data\n\nUnique useful copy, supported claims, descriptive titles and links, accessible image text, schema matching visible content. No fixed title length or word count is a hard SEO rule.\n\n## Measurement and review\n\nBaseline, analytics/consent decisions, measures of success and review dates. Never store credentials or private analytics exports here.\n",
118
- 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md and the relevant Page | tr-edit-site |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
120
+ 'workflows/README.md': "# Project routines\n\nRead only the relevant routine and discover current tool schemas through MCP.\nProject files record intent; the CMS owns current content and configuration.\n\n| Task | Project context | On-demand recipe |\n| --- | --- | --- |\n| Guided start | workflows/interview.md, brief/interview.md | Ask one question at a time; resume confirmed answers |\n| New website | workflows/new-site.md, brief/site.md, brief/brand.md, brief/content-model.md | tr-new-site, tr-starter-design |\n| Migration | workflows/migration.md, brief/migration.md | tr-migration-evidence, then tr-migrate-wp or the matching source recipe |\n| Everyday editing | brief/rules.md, pages/ notes and the relevant Page | tr-edit-site |\n| Page notes and review | workflows/page-notes.md, pages/README.md, pages/index.md | Read before edits; update logs afterward |\n| SEO | brief/seo.md and current site settings | tr-seo |\n| New reusable block | brief/brand.md and recorded editing needs | tr-custom-blocks |\n| Verification and publication | qa/checklist.md, decisions.md | Current preview/deploy tools and publishing readiness |\n\nAdd project-specific routines here with inputs, steps, acceptance evidence and approval boundaries. Do not copy the complete Typeroll manual or private app guides. Discover enabled app guidance with read_app_documentation. Load a recipe when needed so instructions stay current and context stays small.\n",
119
121
  'decisions.md': '# Decisions\n\nRecord date, decision, reason, approved scope and alternatives when useful. For design-token exceptions include the source/block, fixed value, reason (e.g. white text on a photograph) and review criteria.\n',
120
122
  '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',
121
123
  };
@@ -184,11 +186,32 @@ export async function writeWorkspaceFiles(dir, templates, update) {
184
186
  await fs.writeFile(lockFile, JSON.stringify({ schema_version: 1, generator_version: VERSION, files }, null, 2) + '\n');
185
187
  return results;
186
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
+ }
187
211
  export function workspaceClient(config, env, fetchImpl) {
188
212
  if (!env.TYPEROLL_API_KEY)
189
213
  throw new Error('Inject TYPEROLL_API_KEY through private client settings or the environment.');
190
- if (env.TYPEROLL_API_URL && env.TYPEROLL_API_URL.replace(/\/$/, '') !== config.portal.replace(/\/$/, ''))
191
- throw new Error('TYPEROLL_API_URL conflicts with typeroll.json.');
214
+ assertTrustedPortal(config, env);
192
215
  if (env.TYPEROLL_SITE_ID && env.TYPEROLL_SITE_ID !== config.site_id)
193
216
  throw new Error('TYPEROLL_SITE_ID conflicts with typeroll.json.');
194
217
  return new TyperollClient({ baseUrl: config.portal, apiKey: env.TYPEROLL_API_KEY, defaultVersion: config.version, fetchImpl });
@@ -227,7 +250,7 @@ export async function doctor(dir, offline, env = process.env, fetchImpl) {
227
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.' });
228
251
  }
229
252
  catch (error) {
230
- 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.' });
231
254
  }
232
255
  }
233
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.81",
3
+ "version": "0.45.83",
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": {
@@ -19,7 +19,11 @@ description: Edit or create Typeroll pages with a person’s usual AI assistant.
19
19
  content types, or `search_pages` for words in saved content. Ask the user to
20
20
  choose when several pages fit their description. A different host requires
21
21
  confirming a site switch and selecting a new context; never silently switch.
22
- 4. Read the page and relevant Page template, block types and reusable sections.
22
+ 4. When a project repository is available, read its pages/<page-id>.md note and
23
+ relevant decisions before editing. Follow workflows/page-notes.md: update the
24
+ dated log afterward, check external links during reviews, and archive notes
25
+ for confirmed removed Pages. Do not store private notes in public CMS fields.
26
+ Read the page and relevant Page template, block types and reusable sections.
23
27
  Prefer existing native blocks and shared styles. Editors may create custom
24
28
  block types and shared Page templates when needed. Read `tr-custom-blocks`
25
29
  or `tr-page-template` only when that work is relevant. Before changing a
@@ -0,0 +1,40 @@
1
+ # Internal Page notes
2
+
3
+ Keep internal sources, decisions, comments and dated history in this repository,
4
+ not in public CMS fields. The CMS remains the source of current content; notes
5
+ are editorial context, never a second content copy. Keep this workspace private
6
+ if notes contain private information. Never put credentials or signed URLs here.
7
+ Do not include pages/ in static output, content imports or public source bundles.
8
+
9
+ One file per stable Page ID: pages/<encoded-page-id>.md. The helper percent-encodes
10
+ IDs that contain path characters. URLs and titles can change without renaming notes.
11
+ Use a separate workspace/Git branch for another Site or Version; the generated
12
+ facts are bound to portal, Organization, Site and Version and reject mismatches.
13
+
14
+ Each note has review, next_review (YYYY-MM or YYYY-MM-DD), last_checked and
15
+ responsible metadata, followed by Purpose, Sources (including date and whether an
16
+ owner statement), Publishing considerations, Open questions and Log. Use dated
17
+ change, decision, comment and check entries; keep the newest first. Site-wide
18
+ rules belong in decisions.md, referenced from the relevant notes.
19
+
20
+ Choose a review cadence for the project, for example quarterly, annual, event-led
21
+ or when source facts change. There is no default season or automatic review date.
22
+ Refreshing facts never changes last_checked or next_review. Set those only after
23
+ an actual review. index.md sorts scheduled reviews first, then unscheduled Pages.
24
+
25
+ The generated block contains saved CMS metadata and candidate links from the saved
26
+ body. It does not inspect template/partial links, JavaScript or final rendering;
27
+ query-bearing URLs are omitted to avoid copying credentials. Check the rendered
28
+ Page and every external link during review. Sources, historical import statistics
29
+ and their dates belong in the human sections, not invented by the script.
30
+
31
+ Run scripts/typeroll/page_notes.py --dry-run, inspect, then omit --dry-run to refresh.
32
+ --migration-inventory optionally includes recorded redirect sources. Run from the
33
+ workspace root with a privately injected TYPEROLL_API_KEY. No CMS writes occur.
34
+ Only the generated markers and index.md are refreshed; malformed markers fail
35
+ without overwriting notes. Reconcile existing notes manually before adopting the
36
+ new marker/scope format; do not replace an established project's editorial history.
37
+
38
+ Notes without a Page are reported, never deleted automatically. Confirm deletion
39
+ and any redirect, add a final log entry, then move the note to pages/archived/.
40
+ Do not archive just because an API call failed or access changed.
@@ -50,3 +50,10 @@ indexing have distinct effects but do not require repeated approval if covered.
50
50
  updates, future-host candidates and explicit traffic approval.
51
51
  - [ ] Inspect the frozen candidate's noindex/robots output, not only current CMS settings.
52
52
  - [ ] Review owner facts and dates against the chosen preserve/renew text policy.
53
+
54
+ ## Internal Page notes
55
+
56
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
57
+ read before edits, update afterward, check external links at each editorial review,
58
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
59
+ generated facts never counts as a completed review. Keep notes out of public output.
@@ -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,
@@ -109,3 +110,10 @@ uploading or changing CMS content. Missing CMYK profiles, transparency, multiple
109
110
  frames and existing output files stop with an error. `--assume-srgb` is only for
110
111
  reviewed unprofiled RGB/gray; `--source-profile FILE.icc` supplies the actual profile.
111
112
  See workflows/source-media.md for archive verification and original preservation.
113
+
114
+ ## Internal Page notes
115
+
116
+ `page_notes.py --dry-run` previews local note creation/fact refresh and index updates.
117
+ Omit --dry-run to write local files; --migration-inventory adds recorded redirect
118
+ sources. It never writes to the CMS, marks a Page reviewed or deletes notes. Read
119
+ pages/README.md and workflows/page-notes.md before first use.
@@ -0,0 +1,144 @@
1
+ """Refresh repository-only Page facts; never edit CMS content or human notes.
2
+
3
+ Run from the workspace root. --dry-run previews local changes. Optional
4
+ --migration-inventory adds saved migration URL facts. Credentials use trlib.
5
+ """
6
+ import argparse
7
+ from datetime import date
8
+ import hashlib
9
+ from html.parser import HTMLParser
10
+ import json
11
+ from pathlib import Path
12
+ import re
13
+ from urllib.parse import quote, urlsplit
14
+ import trlib
15
+
16
+ START = '<!-- generated:start (page_notes.py; do not edit by hand) -->'
17
+ END = '<!-- generated:end -->'
18
+
19
+
20
+ def filename(page_id):
21
+ if not isinstance(page_id, str) or not page_id or page_id.lower() in ('readme', 'index', 'archived', '.', '..'):
22
+ raise ValueError('Page ID is empty or conflicts with a reserved notes path')
23
+ return quote(page_id, safe='-_') + '.md'
24
+
25
+
26
+ def cell(value):
27
+ return str(value if value is not None else '').replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('|', '&#124;').replace('\n', ' ').replace('\r', ' ')
28
+
29
+
30
+ class Links(HTMLParser):
31
+ def __init__(self):
32
+ super().__init__()
33
+ self.urls = set()
34
+
35
+ def handle_starttag(self, tag, attrs):
36
+ if tag == 'a':
37
+ self.urls.update(v for k, v in attrs if k == 'href' and v)
38
+
39
+
40
+ def body_links(page, blocks):
41
+ parser = Links()
42
+ parser.feed(page.get('html_content') or '')
43
+
44
+ def visit(value, key=''):
45
+ if isinstance(value, dict):
46
+ for k, v in value.items():
47
+ visit(v, k)
48
+ elif isinstance(value, list):
49
+ for v in value:
50
+ visit(v, key)
51
+ elif isinstance(value, str):
52
+ if key in ('href', 'link', 'url', 'button_url', 'link_url'):
53
+ parser.urls.add(value)
54
+ if '<a' in value.lower():
55
+ parser.feed(value)
56
+ for block in trlib.walk(blocks):
57
+ visit(block.get('data') or {})
58
+ # Do not persist signed preview links or query credentials in repository notes.
59
+ return sorted(u for u in parser.urls if not urlsplit(u).query and not urlsplit(u).username
60
+ and urlsplit(u).scheme in ('', 'http', 'https'))
61
+
62
+
63
+ def scope(config):
64
+ return hashlib.sha256(json.dumps([config[k] for k in ('portal', 'organization_id', 'site_id', 'version')]).encode()).hexdigest()
65
+
66
+
67
+ def refresh(root, config, pages, links, migration=(), dry_run=False):
68
+ """Read all inputs before writing. Preserve every byte outside owned markers."""
69
+ out = root / 'pages'
70
+ if out.is_symlink():
71
+ raise ValueError('The notes directory must not be a symlink')
72
+ names = [filename(p['id']) for p in pages]
73
+ if len({n.lower() for n in names}) != len(names):
74
+ raise ValueError('Page IDs collide on a case-insensitive filesystem')
75
+ identity = '<!-- scope: ' + scope(config) + ' -->'
76
+ planned, rows = {}, []
77
+ for p, name in zip(pages, names):
78
+ file = out / name
79
+ if file.is_symlink():
80
+ raise ValueError('A note must not be a symlink')
81
+ text = file.read_text() if file.exists() else (
82
+ '---\nreview: unset\nnext_review: \nlast_checked: \nresponsible: \n---\n\n# ' + cell(p['title']) + '\n\n'
83
+ + START + '\n' + identity + '\n' + END
84
+ + '\n\n## Purpose\n\nTODO\n\n## Sources\n\nTODO\n\n## Publishing considerations\n\nTODO\n\n'
85
+ '## Open questions\n\n- Set a review cadence and next review date.\n\n## Log\n\n- '
86
+ + date.today().isoformat() + ' · comment · Note created; editorial review has not been performed.\n')
87
+ if text.count(START) != 1 or text.count(END) != 1 or text.index(START) >= text.index(END):
88
+ raise ValueError('Missing or ambiguous generated markers in ' + name + '; reconcile manually')
89
+ old = text.split(START)[1].split(END)[0]
90
+ if identity not in old:
91
+ raise ValueError('Note scope differs from the selected Organization/Site/Version: ' + name)
92
+ facts = [START, identity, '', '| Fact | Value |', '| --- | --- |']
93
+ for label, value in [('Page ID', p['id']), ('URL', p.get('url')), ('Title', p['title']),
94
+ ('Status', p.get('status')), ('Content type', p.get('content_type')),
95
+ ('Template', p.get('template')), ('Last saved', p.get('date_updated'))]:
96
+ facts.append('| ' + label + ' | ' + cell(value) + ' |')
97
+ aliases = [m['path'] for m in migration if m.get('status') == 'redirected' and m.get('target', '').rstrip('/') == p.get('url', '').rstrip('/')]
98
+ if aliases:
99
+ facts.append('| Recorded old URLs | ' + cell(', '.join(sorted(aliases))) + ' |')
100
+ facts += ['', 'Saved body link candidates (not a complete rendered-link audit; query URLs omitted):', '']
101
+ facts += ['- ' + cell(u) for u in links.get(p['id'], [])] or ['- None found.']
102
+ facts += ['', END]
103
+ new = text[:text.index(START)] + '\n'.join(facts) + text[text.index(END) + len(END):]
104
+ if new != text:
105
+ planned[file] = new
106
+ elif not file.exists():
107
+ planned[file] = new
108
+ fm = dict(re.findall(r'^(review|next_review|last_checked|responsible):[ \t]*([^\n]*)', new.split('---', 2)[1], re.M)) if new.startswith('---\n') else {}
109
+ nxt = fm.get('next_review', '').strip()
110
+ if nxt and not re.fullmatch(r'\d{4}-\d{2}(?:-\d{2})?', nxt):
111
+ raise ValueError('Use YYYY-MM or YYYY-MM-DD for next_review in ' + name)
112
+ rows.append((nxt or '9999', name, '| ' + (nxt or 'Not scheduled') + ' | [' + cell(p['title']).replace('[', '&#91;').replace(']', '&#93;') + '](' + name + ') | ' + cell(fm.get('review', 'unset')) + ' | ' + cell(fm.get('last_checked', '')) + ' |'))
113
+ index = out / 'index.md'
114
+ if index.is_symlink():
115
+ raise ValueError('The notes index must not be a symlink')
116
+ index_text = '# Page notes index\n\nGenerated from local notes; dates are editorial decisions, not CMS save dates.\n\n| Next review | Page | Cadence | Last checked |\n| --- | --- | --- | --- |\n' + '\n'.join(r[2] for r in sorted(rows)) + '\n'
117
+ if not index.exists() or index.read_text() != index_text:
118
+ planned[index] = index_text
119
+ orphans = sorted(f.name for f in out.glob('*.md') if f.name not in {*names, 'README.md', 'index.md'})
120
+ if not dry_run:
121
+ out.mkdir(parents=True, exist_ok=True)
122
+ for file, text in planned.items():
123
+ file.write_text(text)
124
+ return {'changed': [str(f.relative_to(root)) for f in planned], 'notes_without_page': orphans, 'dry_run': dry_run}
125
+
126
+
127
+ def main():
128
+ parser = argparse.ArgumentParser(description=__doc__)
129
+ parser.add_argument('--dry-run', action='store_true')
130
+ parser.add_argument('--migration-inventory', action='store_true')
131
+ args = parser.parse_args()
132
+ root = Path.cwd()
133
+ if not (root / 'typeroll.json').is_file():
134
+ parser.error('Run from the workspace root containing typeroll.json')
135
+ config = trlib.workspace()
136
+ # list_pages(full=True) reads saved content, unlike read_page's draft overlay.
137
+ pages = list(trlib.all_pages(full=True))
138
+ links = {p['id']: body_links(p, p.get('blocks') or []) for p in pages}
139
+ migration = list(trlib.all_migration_urls()) if args.migration_inventory else []
140
+ print(json.dumps(refresh(root, config, pages, links, migration, args.dry_run), indent=2))
141
+
142
+
143
+ if __name__ == '__main__':
144
+ main()
@@ -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:
@@ -155,10 +179,10 @@ def walk(blocks):
155
179
  yield from walk(slot)
156
180
 
157
181
 
158
- def all_pages():
182
+ def all_pages(full=False):
159
183
  cursor, seen = None, set()
160
184
  while True:
161
- args = {'limit': 200, **({'cursor': cursor} if cursor else {})}
185
+ args = {'limit': 200, **({'full': True} if full else {}), **({'cursor': cursor} if cursor else {})}
162
186
  result = call('list_pages', args)
163
187
  yield from result['pages']
164
188
  cursor = result.get('next_cursor')
@@ -127,3 +127,10 @@ recurring-event wording only when a source supports recurrence. Flag uncertain
127
127
  facts for review or keep new Pages in draft; missing evidence does not prove that
128
128
  an event stopped. Link to the actual organiser when relevant. Upload approved
129
129
  photos with SHA-256 verification and review focal points at mobile/desktop widths.
130
+
131
+ ## Internal Page notes
132
+
133
+ Follow workflows/page-notes.md and pages/README.md. Create notes during migration,
134
+ read before edits, update afterward, check external links at each editorial review,
135
+ and archive notes for confirmed removed Pages under pages/archived/. Refreshing
136
+ generated facts never counts as a completed review. Keep notes out of public output.