@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 +7 -0
- package/README.md +14 -0
- package/dist/index.js +6 -0
- package/dist/init.js +235 -0
- package/dist/install-skills.js +3 -1
- package/dist/server.js +10 -3
- package/dist/tools/forms.js +0 -2
- package/dist/tools/skills.js +104 -0
- package/package.json +1 -1
- package/skills/tr-forms.md +8 -3
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
|
+
}
|
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
|
/**
|
|
@@ -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
|
-
|
|
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);
|
package/dist/tools/forms.js
CHANGED
|
@@ -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.
|
|
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": {
|
package/skills/tr-forms.md
CHANGED
|
@@ -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
|
-
- **
|
|
295
|
-
the portal's Submissions inbox
|
|
296
|
-
|
|
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.
|