@typeroll/mcp-server 0.18.0 → 0.20.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,12 @@ 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
+
10
16
  ## What this is
11
17
 
12
18
  Typeroll is a static-site CMS: content lives in a database, the user
@@ -571,6 +577,7 @@ stakeholder review.
571
577
 
572
578
  | Family | Tools |
573
579
  |---|---|
580
+ | **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. |
574
581
  | **Discovery** | `get_site`, `update_site`, `list_versions`, `read_site_settings` |
575
582
  | **Pages — reads** | `list_pages`, `read_page`, `batch_read_pages` |
576
583
  | **Pages — writes** | `create_page`, `update_page`, `replace_page`, `batch_update_pages`, `delete_page`, `clone_page` |
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,6 +99,14 @@ 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
 
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.
96
110
  - **Discovery** — `get_site`, `update_site` (name/slug/domain), `list_versions`,
97
111
  `read_site_settings`, `update_site_settings`.
98
112
  - **Pages** — list, read, batch-read, create, update (PATCH), replace
package/dist/index.js CHANGED
@@ -14,6 +14,7 @@
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';
@@ -24,6 +25,10 @@ function bail(message) {
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
  /**
@@ -59,6 +60,7 @@ export function buildServer(options) {
59
60
  capabilities: { tools: {} },
60
61
  });
61
62
  const allTools = [
63
+ ...skillTools,
62
64
  ...siteTools,
63
65
  ...pageTools,
64
66
  ...partialTools,
@@ -84,8 +86,11 @@ export function buildServer(options) {
84
86
  const allowedById = new Map((options.allowedSites ?? []).map((s) => [s.siteId, s]));
85
87
  for (const tool of allTools) {
86
88
  const effect = effectFor(tool.name);
89
+ // Skill-discovery tools (and any future site-less tool) operate without a
90
+ // site context: don't inject or validate a `site_id` arg for them.
91
+ const needsSite = !tool.noSite;
87
92
  let schema = tool.inputSchema;
88
- if (isMultiSite) {
93
+ if (isMultiSite && needsSite) {
89
94
  // Append site_id to the tool's existing input schema. We mutate a copy
90
95
  // so we don't pollute the imported ToolDef.
91
96
  const siteIdField = {
@@ -103,7 +108,7 @@ export function buildServer(options) {
103
108
  async (args) => {
104
109
  const rawArgs = args ?? {};
105
110
  let siteId;
106
- if (isMultiSite) {
111
+ if (isMultiSite && needsSite) {
107
112
  const provided = typeof rawArgs.site_id === 'string' ? rawArgs.site_id.trim() : '';
108
113
  if (!provided) {
109
114
  return fail(new Error('site_id is required. This connector covers multiple sites; pick one from list_sites.'));
@@ -121,7 +126,9 @@ export function buildServer(options) {
121
126
  delete rawArgs.site_id;
122
127
  }
123
128
  else {
124
- siteId = options.fixedSiteId;
129
+ // Single-site mode, or a site-less tool in multi-site mode (no
130
+ // fixedSiteId) — the latter's handler ignores siteId entirely.
131
+ siteId = options.fixedSiteId ?? '';
125
132
  }
126
133
  const deps = { client: options.client, siteId };
127
134
  return tool.handler(rawArgs, deps);
@@ -46,7 +46,6 @@ export const formTools = [
46
46
  fields: z.array(fieldSchema).min(1),
47
47
  submit_text: z.string().optional(),
48
48
  success_message: z.string().optional(),
49
- actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
50
49
  },
51
50
  handler: withErrorBoundary(async (args, { client, siteId }) => {
52
51
  const res = await client.post(siteId, 'forms', args);
@@ -63,7 +62,6 @@ export const formTools = [
63
62
  fields: z.array(fieldSchema).optional(),
64
63
  submit_text: z.string().optional(),
65
64
  success_message: z.string().optional(),
66
- actions: z.array(z.object({ type: z.string(), config: z.record(z.unknown()) })).optional(),
67
65
  }),
68
66
  },
69
67
  handler: withErrorBoundary(async (args, { client, siteId }) => {
@@ -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
+ ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@typeroll/mcp-server",
3
- "version": "0.18.0",
3
+ "version": "0.20.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": {
@@ -291,6 +291,11 @@ portal at `/app/sites/{siteId}/forms/kontakt/submissions`.
291
291
  unique per site — use descriptive names: `kontakt`, `boka`, `nyhetsbrev`.
292
292
  - **Honeypot must be invisible.** `_hp` field must have `display:none`.
293
293
  If it's visible and a real user fills it, their submission is rejected.
294
- - **No email notifications yet.** Submissions are stored and visible in
295
- the portal's Submissions inbox; the email action handler isn't wired
296
- platform-side. Confirm with the customer that they'll check the inbox.
294
+ - **Email notifications are admin-only not settable via MCP.** Submissions
295
+ are stored and visible in the portal's Forms → Submissions inbox. A site
296
+ admin can also configure post-submission emails (admin notification +
297
+ autoresponder) under **Forms → <form> → Email**, after setting up an email
298
+ connector under **Settings → Email & notifications**. These carry recipient
299
+ addresses + templates over submission data, so they're deliberately off the
300
+ agent surface (`create_form`/`update_form` ignore `actions`). Point the
301
+ customer at those screens; you can't set them up for them.