@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 +25 -1
- package/README.md +16 -1
- package/dist/client.js +3 -0
- package/dist/index.js +7 -1
- package/dist/init.js +235 -0
- package/dist/install-skills.js +3 -1
- package/dist/server.js +42 -4
- package/dist/tools/block-types.js +1 -1
- package/dist/tools/sites.js +16 -0
- package/dist/tools/skills.js +104 -0
- package/dist/tools/versions.js +1 -1
- package/package.json +1 -1
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
|
-
| **
|
|
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
|
-
- **
|
|
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
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.
|
|
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
|
+
}
|
package/dist/install-skills.js
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
-
|
|
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).'),
|
package/dist/tools/sites.js
CHANGED
|
@@ -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
|
+
];
|
package/dist/tools/versions.js
CHANGED
|
@@ -12,7 +12,7 @@ export const versionTools = [
|
|
|
12
12
|
},
|
|
13
13
|
{
|
|
14
14
|
name: 'create_branch',
|
|
15
|
-
description:
|
|
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.
|
|
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": {
|