@typeroll/mcp-server 0.19.0 → 0.21.0

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/AGENTS.md CHANGED
@@ -7,6 +7,22 @@ what tools to reach for first.
7
7
  If anything below conflicts with what you observe in the tools, trust the
8
8
  tools — the platform may have moved since this was written.
9
9
 
10
+ **Start here for site-shaped tasks.** When the user wants to build,
11
+ migrate, redesign, or brand a site, call `list_skills` first — the server
12
+ advertises its own step-by-step playbook (`tr-new-site`, `tr-migrate-wp`,
13
+ `tr-brand`, …). Then `read_skill name=…` loads the full recipe. These are
14
+ local reads; no API key or site context required.
15
+
16
+ **Branch first for anything larger than a small edit.** Before a redesign,
17
+ a multi-page change, or trying out a new design direction, run
18
+ `create_branch name="…"` and pass the returned id as `version=<id>` on every
19
+ subsequent read/write. The work stays off the live `main` version until you
20
+ `merge_branch` it — nothing ships until you decide it should. Branches default
21
+ `robots_blocked:true` and get their own deploy URL for stakeholder review.
22
+ It's the cheapest insurance there is; when in doubt, branch. The
23
+ `tr-redesign-branch` skill walks the whole flow. (Small, low-risk single edits
24
+ can go straight to main.)
25
+
10
26
  ## What this is
11
27
 
12
28
  Typeroll is a static-site CMS: content lives in a database, the user
@@ -210,6 +226,13 @@ goes through the MCP:
210
226
  You usually want at least #1 + #2 + a sampling from #3 before
211
227
  proposing any design change, so you mirror the conventions in use.
212
228
 
229
+ **Don't have a site yet?** With an org-scoped key you can `create_site
230
+ name="Acme"` — it bootstraps settings + a draft Home page + a published
231
+ header/footer and returns the new site id. Use that id as `site_id`
232
+ (hosted) / `TYPEROLL_SITE_ID` (stdio) for follow-ups, then run
233
+ `list_skills` → `read_skill tr-new-site` to design it. A site-scoped key
234
+ can't create sites (it's bound to one) and gets a 403.
235
+
213
236
  ## Common operations
214
237
 
215
238
  ### "Replace this string across the whole site"
@@ -571,7 +594,8 @@ stakeholder review.
571
594
 
572
595
  | Family | Tools |
573
596
  |---|---|
574
- | **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
597
+ | **Skills (playbook)** | `list_skills`, `read_skill` — the bundled `tr-*.md` recipes, advertised at runtime. Call `list_skills` first when a task looks like "build / migrate / redesign a site", then `read_skill name=…`. No API key or site context needed. |
598
+ | **Discovery** | `get_site`, `create_site` (org-scoped key only — see below), `update_site`, `list_versions`, `read_site_settings` |
575
599
  | **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
576
600
  | **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |
577
601
  | **Pages — blocks** | `get_page_blocks`, `add_block`, `update_block`, `move_block`, `remove_block`, `set_page_mode`, `convert_page_to_blocks` |
package/README.md CHANGED
@@ -63,6 +63,12 @@ client using it stops working immediately.
63
63
  For a self-hosted portal, point `TYPEROLL_API_URL` at it (e.g.
64
64
  `https://cms.example.com`).
65
65
 
66
+ Prefer a scaffold? Run `npx @typeroll/mcp-server init` in your project
67
+ folder — it writes/merges this `.mcp.json`, copies the skills into
68
+ `.claude/skills/`, and adds an `AGENTS.md` pointer + imagegen-lab
69
+ files. Idempotent; `--force` to overwrite. (Skills only:
70
+ `npx @typeroll/mcp-server install-skills .claude/skills`.)
71
+
66
72
  3. **Tell the agent what kind of work you want.** A good first message:
67
73
 
68
74
  > "Connect to Typeroll and tell me what you find — site name,
@@ -93,7 +99,16 @@ which tool.
93
99
  Around 50 tools across these families. See [AGENTS.md](./AGENTS.md) for
94
100
  the full reference + concrete operation recipes.
95
101
 
96
- - **Discovery** — `get_site`, `update_site` (name/slug/domain), `list_versions`,
102
+ - **Skills (self-describing playbook)** — `list_skills` and
103
+ `read_skill`. The server advertises its own bundled recipes at runtime,
104
+ so an agent discovers the platform's playbook (`tr-new-site`,
105
+ `tr-migrate-wp`, `tr-brand`, …) on connection without any files copied
106
+ locally. Call `list_skills` early when the user wants to build /
107
+ migrate / redesign a site, then `read_skill name=tr-new-site` for the
108
+ full markdown. Pure local reads — no API key or site context needed, so
109
+ they work identically on the hosted connector and over stdio.
110
+ - **Discovery** — `get_site`, `create_site` (bootstrap a new site — org-scoped
111
+ key only), `update_site` (name/slug/domain), `list_versions`,
97
112
  `read_site_settings`, `update_site_settings`.
98
113
  - **Pages** — list, read, batch-read, create, update (PATCH), replace
99
114
  (PUT), batch-update, delete, clone, get-preview, `set_page_mode`
package/dist/client.js CHANGED
@@ -100,4 +100,7 @@ export class TyperollClient {
100
100
  rootGet(path) {
101
101
  return this.request('GET', this.rootUrl(path));
102
102
  }
103
+ rootPost(path, body) {
104
+ return this.request('POST', this.rootUrl(path), body);
105
+ }
103
106
  }
package/dist/index.js CHANGED
@@ -14,16 +14,21 @@
14
14
  // so this stdio invocation maps onto one specific site.
15
15
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
16
16
  import { TyperollClient } from './client.js';
17
+ import { runInitCli } from './init.js';
17
18
  import { runInstallSkillsCli } from './install-skills.js';
18
19
  import { resolveSiteId } from './resolve-site-id.js';
19
20
  import { buildServer } from './server.js';
20
- const VERSION = '0.7.12';
21
+ const VERSION = '0.21.0';
21
22
  function bail(message) {
22
23
  console.error(`typeroll-mcp: ${message}`);
23
24
  process.exit(1);
24
25
  }
25
26
  async function main() {
26
27
  const argv = process.argv.slice(2);
28
+ if (argv[0] === 'init') {
29
+ const code = await runInitCli(argv.slice(1));
30
+ process.exit(code);
31
+ }
27
32
  if (argv[0] === 'install-skills') {
28
33
  const code = await runInstallSkillsCli(argv.slice(1));
29
34
  process.exit(code);
@@ -31,6 +36,7 @@ async function main() {
31
36
  if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
32
37
  console.error('Usage:');
33
38
  console.error(' typeroll-mcp Start the MCP server (reads TYPEROLL_API_URL and TYPEROLL_API_KEY)');
39
+ console.error(' typeroll-mcp init [dir] [-f] Bootstrap a project: skills + .mcp.json + AGENTS.md + imagegen lab');
34
40
  console.error(' typeroll-mcp install-skills <dir> [-f] Copy bundled skill files to <dir>');
35
41
  console.error(' typeroll-mcp --help Show this help');
36
42
  process.exit(0);
package/dist/init.js ADDED
@@ -0,0 +1,235 @@
1
+ // `init` subcommand for the typeroll-mcp CLI.
2
+ //
3
+ // A superset of `install-skills`: it bootstraps a LOCAL project directory for
4
+ // agent-driven Typeroll work. On top of copying the bundled skills it writes
5
+ // the connection config (.mcp.json), an AGENTS.md pointer, and the scaffolding
6
+ // the imagegen lab expects (.env.example + images/lab/.gitignore).
7
+ //
8
+ // Like install-skills it's a pure filesystem operation — no network, no API
9
+ // key needed — which is why index.ts dispatches it BEFORE env-var validation.
10
+ //
11
+ // Everything is idempotent: rerunning never clobbers a file the user has
12
+ // edited. `--force` opts into overwriting. Existing values inside .mcp.json
13
+ // are always preserved (we only fill in the keys we own that are missing),
14
+ // even without --force, so re-running can't wipe a key the user pasted in.
15
+ import { promises as fs } from 'node:fs';
16
+ import path from 'node:path';
17
+ import { installSkills } from './install-skills.js';
18
+ const API_URL_DEFAULT = 'https://app.typeroll.com';
19
+ const API_KEY_PLACEHOLDER = 'typeroll_live_REPLACE_WITH_YOUR_KEY';
20
+ const SITE_ID_PLACEHOLDER = 'REPLACE_WITH_YOUR_SITE_ID';
21
+ /** Write `contents` to `file` unless it already exists (idempotent). With
22
+ * `force` it always writes. Returns whether it created or skipped. */
23
+ async function writeIfMissing(file, contents, force) {
24
+ await fs.mkdir(path.dirname(file), { recursive: true });
25
+ if (!force) {
26
+ try {
27
+ await fs.access(file);
28
+ return 'skipped';
29
+ }
30
+ catch {
31
+ // Doesn't exist — fall through to write.
32
+ }
33
+ }
34
+ await fs.writeFile(file, contents, 'utf8');
35
+ return 'created';
36
+ }
37
+ function typerollServerEntry() {
38
+ return {
39
+ command: 'npx',
40
+ args: ['-y', '@typeroll/mcp-server'],
41
+ env: {
42
+ TYPEROLL_API_URL: API_URL_DEFAULT,
43
+ TYPEROLL_API_KEY: API_KEY_PLACEHOLDER,
44
+ TYPEROLL_SITE_ID: SITE_ID_PLACEHOLDER,
45
+ },
46
+ };
47
+ }
48
+ /**
49
+ * Create or merge the `typeroll` entry in `<dir>/.mcp.json`.
50
+ *
51
+ * - No file → create it with just the typeroll server.
52
+ * - File without a typeroll entry → add it, preserving every other server.
53
+ * - File WITH a typeroll entry → fill in only the env keys we own that are
54
+ * missing; never overwrite a value the user already set (unless --force,
55
+ * which replaces the whole entry).
56
+ * - Malformed JSON → left untouched, reported as `skipped` (we refuse to
57
+ * stomp on something we can't safely parse).
58
+ */
59
+ async function writeMcpConfig(dir, force) {
60
+ const file = path.join(dir, '.mcp.json');
61
+ let existing = null;
62
+ try {
63
+ existing = JSON.parse(await fs.readFile(file, 'utf8'));
64
+ }
65
+ catch (e) {
66
+ // ENOENT → brand new file. Any other parse error → don't touch it.
67
+ if (e?.code !== 'ENOENT') {
68
+ return 'skipped';
69
+ }
70
+ }
71
+ if (!existing) {
72
+ const config = { mcpServers: { typeroll: typerollServerEntry() } };
73
+ await fs.mkdir(dir, { recursive: true });
74
+ await fs.writeFile(file, JSON.stringify(config, null, 2) + '\n', 'utf8');
75
+ return 'created';
76
+ }
77
+ const servers = existing.mcpServers ?? {};
78
+ const had = !!servers.typeroll;
79
+ if (!had || force) {
80
+ servers.typeroll = typerollServerEntry();
81
+ }
82
+ else {
83
+ // Merge: keep the user's command/args/env, only add env keys we own that
84
+ // are absent. Never overwrite a value they already pasted in.
85
+ const entry = servers.typeroll;
86
+ entry.env = entry.env ?? {};
87
+ const defaults = typerollServerEntry().env;
88
+ for (const [k, v] of Object.entries(defaults)) {
89
+ if (entry.env[k] === undefined)
90
+ entry.env[k] = v;
91
+ }
92
+ }
93
+ existing.mcpServers = servers;
94
+ await fs.writeFile(file, JSON.stringify(existing, null, 2) + '\n', 'utf8');
95
+ return had && !force ? 'merged' : had ? 'created' : 'merged';
96
+ }
97
+ const AGENTS_POINTER = `# Working on a Typeroll site
98
+
99
+ This project is wired to a Typeroll site through \`@typeroll/mcp-server\`.
100
+
101
+ - **Skills** live in \`.claude/skills/\` (the \`tr-*.md\` recipes). Your agent
102
+ loads them automatically; start by skimming \`tr-new-site\` for the
103
+ bootstrap flow, or run the MCP tool \`list_skills\` to see them all.
104
+ - **Connection** is configured in \`.mcp.json\`. Fill in \`TYPEROLL_API_KEY\`
105
+ (create one in the portal under Settings → API keys) and
106
+ \`TYPEROLL_SITE_ID\`.
107
+ - **Discover before you write.** Call \`get_site\`, \`read_site_settings\`,
108
+ \`list_pages\`, and \`list_block_types\` before proposing changes so you
109
+ mirror the site's conventions.
110
+ - **Image generation** runs locally in \`images/lab/\` — copy
111
+ \`.env.example\` to \`.env\` and add provider keys (see the \`tr-imagegen\`
112
+ skill). Only picked winners get uploaded to the media library.
113
+
114
+ The bundled MCP tools \`list_skills\` / \`read_skill\` expose the full
115
+ playbook at runtime — reach for them first when a task looks like
116
+ "build / migrate / redesign a site".
117
+ `;
118
+ const ENV_EXAMPLE = `# Image-generation lab — local provider keys for the tr-imagegen skill.
119
+ # Copy this file to .env and fill in whichever providers you use. The lab
120
+ # writes candidates to images/lab/ (gitignored); only picked winners are
121
+ # uploaded to the Typeroll media library.
122
+
123
+ # Google Gemini (image generation)
124
+ GEMINI_API_KEY=
125
+
126
+ # OpenAI (image generation)
127
+ OPENAI_API_KEY=
128
+
129
+ # Higgsfield (image / video generation)
130
+ HIGGSFIELD_API_KEY=
131
+ `;
132
+ const LAB_GITIGNORE = `# Generated image candidates — local scratch, never committed.
133
+ # Only winners picked into the media library belong in version control.
134
+ *
135
+ !.gitignore
136
+ `;
137
+ /**
138
+ * Bootstrap `dir` for agent-driven Typeroll work. Idempotent; pass
139
+ * `force` to overwrite existing files.
140
+ */
141
+ export async function runInit(opts) {
142
+ const dir = path.resolve(opts.dir);
143
+ const force = opts.force ?? false;
144
+ await fs.mkdir(dir, { recursive: true });
145
+ const skills = await installSkills({
146
+ dest: path.join(dir, '.claude', 'skills'),
147
+ force,
148
+ sourceDir: opts.sourceDir,
149
+ });
150
+ const files = [];
151
+ files.push({ path: '.mcp.json', action: await writeMcpConfig(dir, force) });
152
+ files.push({
153
+ path: 'AGENTS.md',
154
+ action: await writeIfMissing(path.join(dir, 'AGENTS.md'), AGENTS_POINTER, force),
155
+ });
156
+ files.push({
157
+ path: '.env.example',
158
+ action: await writeIfMissing(path.join(dir, '.env.example'), ENV_EXAMPLE, force),
159
+ });
160
+ files.push({
161
+ path: 'images/lab/.gitignore',
162
+ action: await writeIfMissing(path.join(dir, 'images', 'lab', '.gitignore'), LAB_GITIGNORE, force),
163
+ });
164
+ return { dir, skills, files };
165
+ }
166
+ /**
167
+ * CLI entry point — parses argv (after the `init` token has been removed)
168
+ * and prints human-readable output. Returns an exit code.
169
+ */
170
+ export async function runInitCli(args) {
171
+ let dir;
172
+ let force = false;
173
+ for (const arg of args) {
174
+ if (arg === '--force' || arg === '-f') {
175
+ force = true;
176
+ }
177
+ else if (arg === '--help' || arg === '-h') {
178
+ printHelp();
179
+ return 0;
180
+ }
181
+ else if (!dir && !arg.startsWith('-')) {
182
+ dir = arg;
183
+ }
184
+ else {
185
+ console.error(`typeroll-mcp init: unrecognised argument: ${arg}`);
186
+ printHelp();
187
+ return 1;
188
+ }
189
+ }
190
+ const target = dir ?? '.';
191
+ try {
192
+ const result = await runInit({ dir: target, force });
193
+ const skillsTotal = result.skills.copied.length + result.skills.skipped.length;
194
+ console.log(`typeroll-mcp: initialised project at ${result.dir}`);
195
+ console.log('');
196
+ console.log(` skills .claude/skills/ (${result.skills.copied.length} copied, ${result.skills.skipped.length} kept of ${skillsTotal})`);
197
+ for (const f of result.files) {
198
+ const verb = f.action === 'created' ? 'wrote' : f.action === 'merged' ? 'merged' : 'kept';
199
+ console.log(` ${verb.padEnd(9)} ${f.path}`);
200
+ }
201
+ console.log('');
202
+ console.log('Next steps:');
203
+ console.log(' 1. Create an API key in the Typeroll portal (Settings → API keys).');
204
+ console.log(' 2. Edit .mcp.json: set TYPEROLL_API_KEY and TYPEROLL_SITE_ID.');
205
+ console.log(' 3. (Optional) cp .env.example .env and add image-provider keys.');
206
+ console.log(' 4. Open this folder in Claude Code — it picks up .mcp.json + .claude/skills/.');
207
+ console.log(' 5. Ask the agent to "connect to Typeroll and confirm the key works".');
208
+ if (result.files.some((f) => f.action === 'skipped')) {
209
+ console.log('');
210
+ console.log('Some files already existed and were kept — rerun with --force to overwrite.');
211
+ }
212
+ return 0;
213
+ }
214
+ catch (e) {
215
+ const reason = e instanceof Error ? e.message : String(e);
216
+ console.error(`typeroll-mcp init: ${reason}`);
217
+ return 1;
218
+ }
219
+ }
220
+ function printHelp() {
221
+ console.error('');
222
+ console.error('Usage: npx @typeroll/mcp-server init [directory] [--force]');
223
+ console.error('');
224
+ console.error('Bootstraps a local project for agent-driven Typeroll work:');
225
+ console.error(' - copies the bundled skills to .claude/skills/');
226
+ console.error(' - writes/merges .mcp.json with a typeroll server entry');
227
+ console.error(' - writes an AGENTS.md pointer, .env.example, images/lab/.gitignore');
228
+ console.error('');
229
+ console.error('Directory defaults to the current folder. Idempotent: rerunning');
230
+ console.error('never clobbers your edits, and existing .mcp.json values are kept.');
231
+ console.error('');
232
+ console.error('Options:');
233
+ console.error(' --force, -f Overwrite existing files (replaces the typeroll .mcp.json entry)');
234
+ console.error(' --help, -h Show this help');
235
+ }
@@ -14,7 +14,9 @@ import { fileURLToPath } from 'node:url';
14
14
  // Resolve the bundled skills directory. At runtime this file lives at
15
15
  // <package>/dist/install-skills.js (after tsc); skills/ is a sibling of dist/.
16
16
  // fileURLToPath gives us a real OS path that works on every platform.
17
- function skillsDir() {
17
+ // Exported so the skill-discovery tools (tools/skills.ts) resolve the exact
18
+ // same directory without duplicating the `..`/`skills` dance.
19
+ export function skillsDir() {
18
20
  const here = path.dirname(fileURLToPath(import.meta.url));
19
21
  return path.resolve(here, '..', 'skills');
20
22
  }
package/dist/server.js CHANGED
@@ -25,6 +25,7 @@ import { pageBlockTools } from './tools/page-blocks.js';
25
25
  import { settingsTools } from './tools/settings.js';
26
26
  import { siteTools } from './tools/sites.js';
27
27
  import { domainTools } from './tools/domain.js';
28
+ import { skillTools } from './tools/skills.js';
28
29
  import { fail } from './tools/helpers.js';
29
30
  const PERM_RANK = { read: 0, write: 1, admin: 2 };
30
31
  /**
@@ -47,7 +48,37 @@ function effectFor(name) {
47
48
  }
48
49
  return 'write';
49
50
  }
50
- const DEFAULT_INFO = { name: 'typeroll', version: '0.7.12' };
51
+ const DEFAULT_INFO = { name: 'typeroll', version: '0.21.0' };
52
+ /**
53
+ * Server-level instructions — returned in the MCP `initialize` response and
54
+ * surfaced to the model by every client (Claude Code stdio AND the hosted
55
+ * Desktop/claude.ai connector) with zero user setup. This is the one channel
56
+ * that reaches every consumer automatically, so it carries the highest-value
57
+ * conventions and POINTS at the deeper, on-demand content (the bundled skills,
58
+ * reachable via list_skills/read_skill) rather than duplicating it.
59
+ */
60
+ export const SERVER_INSTRUCTIONS = `
61
+ Typeroll MCP — operating manual. You're managing a Typeroll site (a static-site
62
+ CMS: database content compiles to a fast static site on a deploy). The full
63
+ playbook ships with this server — use it:
64
+
65
+ 1. When the task is "build / migrate / redesign / brand a site", call
66
+ list_skills FIRST, then read_skill <name> for the step-by-step recipe
67
+ (tr-new-site, tr-migrate-wp, tr-brand, tr-blog, …). These are the canonical
68
+ how-to; don't improvise what a skill already covers.
69
+ 2. Discover before you write: get_site, read_site_settings, list_pages,
70
+ list_block_types. Never hardcode block ids or field names — they're per-site.
71
+ 3. Branch first for anything larger than a small edit: create_branch, pass
72
+ version=<id> on every subsequent call, merge_branch once approved. Nothing
73
+ touches the live site until then.
74
+ 4. Pages default to block mode. Build with add_block/update_block; make layouts
75
+ responsive per breakpoint with set_block_responsive (grid columns, icon-box
76
+ layout, … take { mobile, tablet, laptop, desktop, wide } values).
77
+ 5. No site yet? With an org-scoped key, create_site bootstraps one.
78
+
79
+ If anything here conflicts with what a tool returns, trust the tool. Every
80
+ tool's own description carries its specifics.
81
+ `.trim();
51
82
  export function buildServer(options) {
52
83
  if (!options.fixedSiteId && !options.allowedSites) {
53
84
  throw new Error('buildServer: either fixedSiteId or allowedSites must be provided');
@@ -57,8 +88,10 @@ export function buildServer(options) {
57
88
  }
58
89
  const server = new McpServer(options.info ?? DEFAULT_INFO, {
59
90
  capabilities: { tools: {} },
91
+ instructions: SERVER_INSTRUCTIONS,
60
92
  });
61
93
  const allTools = [
94
+ ...skillTools,
62
95
  ...siteTools,
63
96
  ...pageTools,
64
97
  ...partialTools,
@@ -84,8 +117,11 @@ export function buildServer(options) {
84
117
  const allowedById = new Map((options.allowedSites ?? []).map((s) => [s.siteId, s]));
85
118
  for (const tool of allTools) {
86
119
  const effect = effectFor(tool.name);
120
+ // Skill-discovery tools (and any future site-less tool) operate without a
121
+ // site context: don't inject or validate a `site_id` arg for them.
122
+ const needsSite = !tool.noSite;
87
123
  let schema = tool.inputSchema;
88
- if (isMultiSite) {
124
+ if (isMultiSite && needsSite) {
89
125
  // Append site_id to the tool's existing input schema. We mutate a copy
90
126
  // so we don't pollute the imported ToolDef.
91
127
  const siteIdField = {
@@ -103,7 +139,7 @@ export function buildServer(options) {
103
139
  async (args) => {
104
140
  const rawArgs = args ?? {};
105
141
  let siteId;
106
- if (isMultiSite) {
142
+ if (isMultiSite && needsSite) {
107
143
  const provided = typeof rawArgs.site_id === 'string' ? rawArgs.site_id.trim() : '';
108
144
  if (!provided) {
109
145
  return fail(new Error('site_id is required. This connector covers multiple sites; pick one from list_sites.'));
@@ -121,7 +157,9 @@ export function buildServer(options) {
121
157
  delete rawArgs.site_id;
122
158
  }
123
159
  else {
124
- siteId = options.fixedSiteId;
160
+ // Single-site mode, or a site-less tool in multi-site mode (no
161
+ // fixedSiteId) — the latter's handler ignores siteId entirely.
162
+ siteId = options.fixedSiteId ?? '';
125
163
  }
126
164
  const deps = { client: options.client, siteId };
127
165
  return tool.handler(rawArgs, deps);
@@ -92,7 +92,7 @@ export const blockTypeTools = [
92
92
  // audit + UI distinguish agent-authored types.
93
93
  {
94
94
  name: 'create_block_type',
95
- description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. Returns the created BlockType.",
95
+ description: "Create a new custom block type usable on this site. Origin is stamped 'ai' automatically. The block ships immediately to every page editor + the renderer's registry. Custom JS via `script` is accepted under your API key's authority (audit-logged; the response carries a notice) — review before deploy. Returns the created BlockType. Responsive fields: mark a schema field `responsive: true` and expose it as style=\"--{field}:{{field}}\" on the outermost element so it can vary per breakpoint. If the value is directly usable CSS, read var(--{field}); if it's a token that maps to CSS (e.g. layout 'icon-left' → flex-direction:row) add a `responsive_css` map on the field ({ 'icon-left': '--dir: row;' }), otherwise per-breakpoint overrides won't take effect.",
96
96
  inputSchema: {
97
97
  name: z.string().describe('Machine name (lowercase kebab/underscore, 1-64 chars). Becomes the id.'),
98
98
  label: z.string().optional().describe('Display label (defaults to name).'),
@@ -2,6 +2,22 @@
2
2
  import { z } from 'zod';
3
3
  import { ok, withErrorBoundary } from './helpers.js';
4
4
  export const siteTools = [
5
+ {
6
+ name: 'create_site',
7
+ description: "Create + bootstrap a NEW site in your org. Seeds default settings, a draft Home page, and a published header/footer so it renders immediately. Requires an ORG-scoped key (a site-scoped key is bound to one existing site and can't mint new ones; you'll get a 403). `name` drives a kebab-case site id; pass `domain` to kick off the \"point your DNS\" flow (never written as a live domain). Returns the new site's id + urls — use that id as `site_id`/`TYPEROLL_SITE_ID` for follow-up calls. After creating, run list_skills → read_skill tr-new-site to bootstrap the design.",
8
+ noSite: true,
9
+ inputSchema: {
10
+ name: z.string().min(1).describe('Display name. Slugified into the site id.'),
11
+ domain: z
12
+ .string()
13
+ .optional()
14
+ .describe('Optional real hostname e.g. "example.com". Starts DNS setup; not set live until DNS verifies.'),
15
+ },
16
+ handler: withErrorBoundary(async (args, { client }) => {
17
+ const res = await client.rootPost('sites', { name: args.name, domain: args.domain });
18
+ return ok(res);
19
+ }),
20
+ },
5
21
  {
6
22
  name: 'get_site',
7
23
  description: 'Read this site\'s metadata (id, name, slug, domain, active version) + a urls object covering the production / fallback / preview_base URLs. Useful as a first call to confirm the key is wired up and to learn what URLs the site is reachable at.',
@@ -0,0 +1,104 @@
1
+ // Skill-discovery tools — the self-describing playbook layer.
2
+ //
3
+ // The package bundles a set of `tr-*.md` skill recipes under `skills/`. Until
4
+ // now they were only useful if the operator happened to copy them into
5
+ // `.claude/skills/` (see install-skills.ts) — the running MCP server never
6
+ // advertised them, so an agent had no way to learn that e.g. `tr-new-site`
7
+ // exists. These two tools fix that by exposing the bundled skills directly
8
+ // on the tool surface, identically over stdio and the hosted HTTP transport.
9
+ //
10
+ // Both are pure LOCAL file reads: they do NOT touch the REST API and need
11
+ // neither TYPEROLL_API_KEY nor TYPEROLL_API_URL nor a site context. That's
12
+ // why they're flagged `noSite: true` — in multi-site (hosted) mode the
13
+ // server skips the usual `site_id` injection/validation for them.
14
+ import { promises as fs } from 'node:fs';
15
+ import path from 'node:path';
16
+ import { z } from 'zod';
17
+ import { skillsDir } from '../install-skills.js';
18
+ import { ok, withErrorBoundary } from './helpers.js';
19
+ /** The only basenames `read_skill` will resolve. Matches the install-skills
20
+ * copy filter (`tr-*.md`) and, because it forbids slashes and dots, is also
21
+ * the primary defence against path traversal. */
22
+ export const SKILL_NAME_RE = /^tr-[a-z0-9-]+$/;
23
+ /**
24
+ * Pull `name:` / `description:` out of a skill file's YAML frontmatter.
25
+ *
26
+ * The frontmatter is a flat block we author by hand (two single-line keys),
27
+ * so a deliberately tiny regex parse beats pulling in a YAML dependency.
28
+ * Falls back to the filename-derived name when `name:` is absent and to an
29
+ * empty description when `description:` is.
30
+ */
31
+ export function parseSkillFrontmatter(markdown, fallbackName) {
32
+ let name = fallbackName;
33
+ let description = '';
34
+ const fm = markdown.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/);
35
+ if (fm) {
36
+ const body = fm[1];
37
+ const nameLine = body.match(/^name:\s*(.+?)\s*$/m);
38
+ const descLine = body.match(/^description:\s*(.+?)\s*$/m);
39
+ if (nameLine)
40
+ name = nameLine[1].trim();
41
+ if (descLine)
42
+ description = descLine[1].trim();
43
+ }
44
+ return { name, description };
45
+ }
46
+ /** List the bundled skills (name + description) from `sourceDir`. Pure read;
47
+ * `sourceDir` defaults to the bundled `skills/` dir but is injectable for
48
+ * tests. */
49
+ export async function listBundledSkills(sourceDir = skillsDir()) {
50
+ const entries = await fs.readdir(sourceDir);
51
+ const files = entries.filter((f) => f.startsWith('tr-') && f.endsWith('.md')).sort();
52
+ const skills = [];
53
+ for (const file of files) {
54
+ const raw = await fs.readFile(path.join(sourceDir, file), 'utf8');
55
+ skills.push(parseSkillFrontmatter(raw, file.replace(/\.md$/, '')));
56
+ }
57
+ return skills;
58
+ }
59
+ /** Read one bundled skill's markdown by name. Rejects anything that isn't a
60
+ * `tr-<kebab>` basename and, belt-and-suspenders, verifies the resolved path
61
+ * stays inside `sourceDir`. */
62
+ export async function readBundledSkill(name, sourceDir = skillsDir()) {
63
+ if (!SKILL_NAME_RE.test(name)) {
64
+ throw new Error(`invalid skill name "${name}" — expected tr-<kebab-case>, e.g. tr-new-site. Use list_skills to see available skills.`);
65
+ }
66
+ const root = path.resolve(sourceDir);
67
+ const target = path.resolve(root, `${name}.md`);
68
+ // The regex already blocks `/` and `.`, so traversal can't get here — but
69
+ // confirm the join landed exactly where we expect before touching disk.
70
+ if (path.dirname(target) !== root) {
71
+ throw new Error(`refusing to read outside the skills directory: ${name}`);
72
+ }
73
+ try {
74
+ return await fs.readFile(target, 'utf8');
75
+ }
76
+ catch {
77
+ throw new Error(`skill "${name}" not found. Use list_skills to see available skills.`);
78
+ }
79
+ }
80
+ export const skillTools = [
81
+ {
82
+ name: 'list_skills',
83
+ description: 'List the bundled Typeroll playbook skills (name + description). Call this EARLY — the moment the user wants to build, migrate, redesign, brand, or otherwise design a site — to discover the step-by-step recipe that fits (e.g. tr-new-site, tr-migrate-wp, tr-brand, tr-blog), then read_skill the most relevant one before acting. Pure local read: no API key and no site required, so it works on the hosted connector and stdio alike.',
84
+ noSite: true,
85
+ handler: withErrorBoundary(async () => {
86
+ const skills = await listBundledSkills();
87
+ return ok({ skills, count: skills.length });
88
+ }),
89
+ },
90
+ {
91
+ name: 'read_skill',
92
+ description: 'Return the full markdown of one bundled skill by name (e.g. "tr-new-site"). Use it after list_skills to load the playbook for the task at hand. Pure local read: no API key and no site required.',
93
+ inputSchema: {
94
+ name: z
95
+ .string()
96
+ .describe('Skill name without the .md extension, e.g. "tr-new-site". Must match /^tr-[a-z0-9-]+$/.'),
97
+ },
98
+ noSite: true,
99
+ handler: withErrorBoundary(async (args) => {
100
+ const content = await readBundledSkill(args.name);
101
+ return ok(content);
102
+ }),
103
+ },
104
+ ];
@@ -12,7 +12,7 @@ export const versionTools = [
12
12
  },
13
13
  {
14
14
  name: 'create_branch',
15
- description: 'Create a copy-on-write branch from main (or the version passed in `base`). New branches default to robots_blocked:true. Pass the new branch\'s id as ?version= on subsequent calls to read/write against it.',
15
+ description: "Create a copy-on-write branch from main (or the version passed in `base`) — the RECOMMENDED first step for any larger or experimental change (redesigns, multi-page edits, trying a new design direction). Work lands on the branch, never on the live main version, until you merge_branch it. New branches default to robots_blocked:true (a half-finished design can't be indexed) and get their own deploy URL ({branch}.{project}.pages.dev) for stakeholder review. Pass the returned id as ?version= on every subsequent read/write. When in doubt, branch — it's cheap and keeps the live site safe. The tr-redesign-branch skill (read_skill) walks the full flow.",
16
16
  inputSchema: {
17
17
  name: z.string().min(1),
18
18
  base: z.string().optional().describe('Source version id; defaults to main.'),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Model Context Protocol server for the Typeroll public API. Use with Claude Code or any MCP-compatible client to manage a Typeroll site.",
5
5
  "license": "MIT",
6
6
  "repository": {