@typeroll/mcp-server 0.45.21 → 0.45.23

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/dist/client.js CHANGED
@@ -16,6 +16,7 @@ export class ApiError extends Error {
16
16
  }
17
17
  }
18
18
  export class TyperollClient {
19
+ defaultVersion;
19
20
  baseUrl;
20
21
  apiKey;
21
22
  fetchImpl;
@@ -26,12 +27,15 @@ export class TyperollClient {
26
27
  throw new Error('apiKey is required');
27
28
  this.baseUrl = config.baseUrl.replace(/\/+$/, '');
28
29
  this.apiKey = config.apiKey;
30
+ this.defaultVersion = config.defaultVersion;
29
31
  this.fetchImpl = config.fetchImpl ?? globalThis.fetch.bind(globalThis);
30
32
  }
31
33
  /** Build the full URL for a path (relative to /api/v1/sites/{siteId}/...) */
32
34
  url(siteId, path, query) {
33
35
  const cleanPath = path.replace(/^\/+/, '');
34
36
  const url = new URL(`${this.baseUrl}/api/v1/sites/${encodeURIComponent(siteId)}/${cleanPath}`);
37
+ if (this.defaultVersion)
38
+ url.searchParams.set('version', this.defaultVersion);
35
39
  if (query) {
36
40
  for (const [k, v] of Object.entries(query)) {
37
41
  if (v !== undefined && v !== null)
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ import { zodToJsonSchema } from 'zod-to-json-schema';
3
+ import { fail, ok } from './tools/helpers.js';
4
+ export const COMPACT_INSTRUCTIONS = `Typeroll CMS. Use search_tools to find a task's tools, describe_tool to read one exact schema, then call_read_tool, call_write_tool or call_admin_tool. Discover before you write: identify Site/Version, capabilities and enabled app documentation. Read only the relevant skill or guide section; do not load the full manual by default. Larger changes: create_branch and keep the same version. Working copy, saved content and live output differ. Publishing and other external effects require the user's authorization; tool access alone is not approval.`;
5
+ /** Stable discovery surface: no dynamic registration or client list-change support needed. */
6
+ export function compactTools(catalog) {
7
+ const byName = new Map(catalog.map(tool => [tool.name, tool]));
8
+ const local = (name, description, schema, invoke) => ({ name, description, schema, effect: 'read', invoke });
9
+ return [
10
+ local('search_tools', 'Find tools by name or task. Returns brief matches without schemas; use describe_tool next. Empty query lists names in pages.', {
11
+ query: z.string().max(200).default(''), offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(12).default(8),
12
+ }, async (args) => {
13
+ const words = String(args.query).toLowerCase().split(/\s+/).filter(Boolean);
14
+ const matches = catalog.map(tool => ({ tool, score: words.reduce((sum, word) => sum + (tool.name === word ? 10 : tool.name.includes(word) ? 3 : tool.description.toLowerCase().includes(word) ? 1 : 0), 0) }))
15
+ .filter(item => !words.length || item.score > 0).sort((a, b) => b.score - a.score || a.tool.name.localeCompare(b.tool.name));
16
+ const offset = Number(args.offset), limit = Number(args.limit);
17
+ return ok({ tools: matches.slice(offset, offset + limit).map(({ tool }) => ({ name: tool.name, effect: tool.effect, description: tool.description.slice(0, 180) })), total: matches.length, next_offset: offset + limit < matches.length ? offset + limit : null });
18
+ }),
19
+ local('describe_tool', 'Read one tool’s complete description, effect and JSON input schema before calling it.', { name: z.string().max(100) }, async (args) => {
20
+ const tool = byName.get(String(args.name));
21
+ return tool ? ok({ name: tool.name, effect: tool.effect, description: tool.description, inputSchema: zodToJsonSchema(z.object(tool.schema), { $refStrategy: 'none' }) }) : fail(new Error('Unknown tool. Use search_tools.'));
22
+ }),
23
+ ...['read', 'write', 'admin'].map(effect => ({
24
+ name: `call_${effect}_tool`, effect,
25
+ description: effect === 'read' ? 'Call a discovered read-only tool with its exact arguments. Does not accept write/admin tools.' : `Call a discovered ${effect} tool. May ${effect === 'admin' ? 'change app configuration or activate releases' : 'save, delete, publish or otherwise change state'}. Inspect describe_tool first and follow the user’s authorization.`,
26
+ schema: { name: z.string().max(100), arguments: z.record(z.unknown()).default({}) },
27
+ invoke: async (args) => {
28
+ const tool = byName.get(String(args.name));
29
+ if (!tool || tool.effect !== effect)
30
+ return fail(new Error(`This endpoint accepts only ${effect} tools. Use describe_tool to check the required effect.`));
31
+ try {
32
+ return await tool.invoke(z.object(tool.schema).parse(args.arguments));
33
+ }
34
+ catch (error) {
35
+ return fail(error);
36
+ }
37
+ },
38
+ })),
39
+ ];
40
+ }
package/dist/index.js CHANGED
@@ -20,12 +20,39 @@ import { resolveSiteId } from './resolve-site-id.js';
20
20
  import { buildServer } from './server.js';
21
21
  import { VERSION } from './version.js';
22
22
  import { runExtensionCli } from './extension-cli.js';
23
+ import { doctor, readWorkspace, workspaceClient, verifyWorkspaceBinding } from './workspace.js';
23
24
  function bail(message) {
24
25
  console.error(`typeroll-mcp: ${message}`);
25
26
  process.exit(1);
26
27
  }
27
28
  async function main() {
28
29
  const argv = process.argv.slice(2);
30
+ if (argv[0] === 'doctor') {
31
+ const args = argv.slice(1);
32
+ if (args.includes('--help')) {
33
+ console.log('Usage: typeroll doctor [directory] [--offline] [--json]');
34
+ return;
35
+ }
36
+ if (args.some(arg => arg.startsWith('-') && !['--offline', '--json'].includes(arg)) || args.filter(arg => !arg.startsWith('-')).length > 1)
37
+ bail('Usage: doctor [directory] [--offline] [--json]');
38
+ const result = await doctor(args.find(arg => !arg.startsWith('-')) ?? '.', args.includes('--offline'));
39
+ console.log(args.includes('--json') ? JSON.stringify(result, null, 2) : result.checks.map(check => `${check.status}: ${check.check} — ${check.message}`).join('\n'));
40
+ process.exitCode = result.passed ? 0 : 1;
41
+ return;
42
+ }
43
+ if (argv[0] === 'workspace-mcp') {
44
+ if (argv.length > 2)
45
+ bail('Usage: workspace-mcp [directory]');
46
+ const config = await readWorkspace(argv[1] ?? '.');
47
+ const client = workspaceClient(config, process.env);
48
+ if (config.site_id)
49
+ await verifyWorkspaceBinding(config, client);
50
+ // An unbound workspace permits discovery/site creation only. Save the Site
51
+ // binding and reconnect before content operations; never guess a target.
52
+ const server = buildServer({ client, ...(config.site_id ? { fixedSiteId: config.site_id } : { allowedSites: [] }), toolMode: config.tool_mode, info: { name: 'typeroll', version: VERSION } });
53
+ await server.connect(new StdioServerTransport());
54
+ return;
55
+ }
29
56
  if (argv[0] === 'init') {
30
57
  const code = await runInitCli(argv.slice(1));
31
58
  process.exit(code);
@@ -41,12 +68,19 @@ async function main() {
41
68
  if (argv[0] === '--help' || argv[0] === '-h' || argv[0] === 'help') {
42
69
  console.error('Usage:');
43
70
  console.error(' typeroll-mcp Start the MCP server (reads TYPEROLL_API_URL and TYPEROLL_API_KEY)');
44
- console.error(' typeroll-mcp init [dir] [-f] Bootstrap a project: skills + .mcp.json + AGENTS.md + imagegen lab');
71
+ console.error(' typeroll-mcp init [dir] [--update] Create/update an agent-neutral workspace (see init --help)');
45
72
  console.error(' typeroll-mcp install-skills <dir> [-f] Copy bundled skill files to <dir>');
73
+ console.error(' typeroll-mcp doctor [dir] [--offline] Read-only workspace/connection checks');
74
+ console.error(' typeroll-mcp workspace-mcp [dir] MCP bound to typeroll.json (compact by default)');
46
75
  console.error(' typeroll extension <command> Validate, push, install, configure or promote an Extension');
47
76
  console.error(' typeroll-mcp --help Show this help');
48
77
  process.exit(0);
49
78
  }
79
+ if (argv.length)
80
+ bail('Unknown command. Use --help.');
81
+ const mode = process.env.TYPEROLL_MCP_TOOL_MODE ?? 'full';
82
+ if (!['full', 'compact'].includes(mode))
83
+ bail('TYPEROLL_MCP_TOOL_MODE must be full or compact.');
50
84
  const apiUrl = process.env.TYPEROLL_API_URL?.trim();
51
85
  const apiKey = process.env.TYPEROLL_API_KEY?.trim();
52
86
  if (!apiUrl)
@@ -67,6 +101,7 @@ async function main() {
67
101
  const server = buildServer({
68
102
  client,
69
103
  fixedSiteId: siteId,
104
+ toolMode: mode,
70
105
  info: { name: 'typeroll', version: VERSION },
71
106
  });
72
107
  const transport = new StdioServerTransport();
package/dist/init.js CHANGED
@@ -1,257 +1,58 @@
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
1
  import { promises as fs } from 'node:fs';
16
2
  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
- - **One Page model (Core 0.2.0 / MCP 0.45.0).** Every article, checklist,
108
- directory entry and ordinary page is a Page. A \`content_type\` supplies its
109
- schema, URL pattern and default Page template; custom values live in \`fields\`.
110
- Use \`create_page\`, \`list_pages content_type=...\` and Page IDs everywhere.
111
- Use the same \`version\` for Pages, types, templates and previews. Existing
112
- installations require the schema 2 migration before using this model.
113
- - **Discover before you write.** Call \`get_site\`, \`read_site_settings\`,
114
- \`list_pages\`, and \`list_block_types\` before proposing changes so you
115
- mirror the site's conventions.
116
- - **Source of truth = the live site (the API), by default.** What you read
117
- back through the MCP (\`read_page\`, \`read_partial\`, \`read_site_settings\`)
118
- is canonical. Files in this folder — \`sources/*.md\` copy drafts, briefs —
119
- are PROPOSALS; treat them as authoritative only when explicitly told "use
120
- the copy in \`<file>\`". When rebuilding/redesigning, derive copy from the
121
- live page, not a local draft. If you edit copy on the live site, sync it
122
- back to the draft file in the same pass so they don't diverge.
123
- - **A design review covers appearance AND readability, not just structure.**
124
- Before calling a build/redesign done, screenshot it at desktop (~1440px) and
125
- mobile (~390px) and judge the visuals: logo fully visible (not clipped by a
126
- header's overflow:hidden) + legible + brand-compliant against its real background
127
- — screenshot the header in context, not the logo element in isolation (that hides
128
- layout clipping); a light wordmark must not sit bare on a light surface. Text
129
- contrast, no horizontal scroll or mid-word breaks, all images rendered, mobile
130
- layout collapsed. "Copy present + no overflow" is not a design review — never
131
- call a design perfect/approved off structural metrics alone.
132
- - **Image generation** runs locally in \`images/lab/\` — copy
133
- \`.env.example\` to \`.env\` and add provider keys (see the \`tr-imagegen\`
134
- skill). Only picked winners get uploaded to the media library.
135
-
136
- The bundled MCP tools \`list_skills\` / \`read_skill\` expose the full
137
- playbook at runtime — reach for them first when a task looks like
138
- "build / migrate / redesign a site".
139
- `;
140
- const ENV_EXAMPLE = `# Image-generation lab — local provider keys for the tr-imagegen skill.
141
- # Copy this file to .env and fill in whichever providers you use. The lab
142
- # writes candidates to images/lab/ (gitignored); only picked winners are
143
- # uploaded to the Typeroll media library.
144
-
145
- # Google Gemini (image generation)
146
- GEMINI_API_KEY=
147
-
148
- # OpenAI (image generation)
149
- OPENAI_API_KEY=
150
-
151
- # Higgsfield (image / video generation)
152
- HIGGSFIELD_API_KEY=
153
- `;
154
- const LAB_GITIGNORE = `# Generated image candidates — local scratch, never committed.
155
- # Only winners picked into the media library belong in version control.
156
- *
157
- !.gitignore
158
- `;
159
- /**
160
- * Bootstrap `dir` for agent-driven Typeroll work. Idempotent; pass
161
- * `force` to overwrite existing files.
162
- */
163
- export async function runInit(opts) {
164
- const dir = path.resolve(opts.dir);
165
- const force = opts.force ?? false;
166
- await fs.mkdir(dir, { recursive: true });
167
- const skills = await installSkills({
168
- dest: path.join(dir, '.claude', 'skills'),
169
- force,
170
- sourceDir: opts.sourceDir,
171
- });
172
- const files = [];
173
- files.push({ path: '.mcp.json', action: await writeMcpConfig(dir, force) });
174
- files.push({
175
- path: 'AGENTS.md',
176
- action: await writeIfMissing(path.join(dir, 'AGENTS.md'), AGENTS_POINTER, force),
177
- });
178
- files.push({
179
- path: '.env.example',
180
- action: await writeIfMissing(path.join(dir, '.env.example'), ENV_EXAMPLE, force),
181
- });
182
- files.push({
183
- path: 'images/lab/.gitignore',
184
- action: await writeIfMissing(path.join(dir, 'images', 'lab', '.gitignore'), LAB_GITIGNORE, force),
185
- });
186
- return { dir, skills, files };
3
+ import { skillsDir } from './install-skills.js';
4
+ import { VERSION } from './version.js';
5
+ import { defaultWorkspace, workspaceTemplates, writeWorkspaceFiles } from './workspace.js';
6
+ export async function runInit(options) {
7
+ const dir = path.resolve(options.dir);
8
+ const templates = { ...workspaceTemplates, 'typeroll.json': JSON.stringify(defaultWorkspace, null, 2) + '\n' };
9
+ if (options.recipes) {
10
+ const source = options.sourceDir ?? skillsDir();
11
+ for (const file of (await fs.readdir(source)).filter(file => /^tr-[a-z0-9-]+\.md$/.test(file)))
12
+ templates[`typeroll-skills/${file}`] = await fs.readFile(path.join(source, file), 'utf8');
13
+ }
14
+ const client = options.client ?? 'none';
15
+ if (client !== 'none') {
16
+ const entry = { command: 'npx', args: ['-y', `@typeroll/mcp-server@${VERSION}`, 'workspace-mcp', dir] };
17
+ const file = client === 'claude' ? '.mcp.json' : client === 'cursor' ? '.cursor/mcp.json' : '.vscode/mcp.json';
18
+ // The hash-based updater replaces only an unchanged generated file. It never
19
+ // merges a user-owned configuration, credentials or another server entry.
20
+ templates[file] = JSON.stringify(client === 'vscode' ? { servers: { typeroll: { type: 'stdio', ...entry } } } : { mcpServers: { typeroll: entry } }, null, 2) + '\n';
21
+ }
22
+ const files = await writeWorkspaceFiles(dir, templates, options.update ?? false);
23
+ return { dir, generator_version: VERSION, files, client, recipes: options.recipes ?? false };
187
24
  }
188
- /**
189
- * CLI entry point — parses argv (after the `init` token has been removed)
190
- * and prints human-readable output. Returns an exit code.
191
- */
192
25
  export async function runInitCli(args) {
193
- let dir;
194
- let force = false;
195
- for (const arg of args) {
196
- if (arg === '--force' || arg === '-f') {
197
- force = true;
198
- }
199
- else if (arg === '--help' || arg === '-h') {
200
- printHelp();
26
+ let dir = '.', update = false, recipes = false, client = 'none', hadDir = false;
27
+ for (let index = 0; index < args.length; index++) {
28
+ const arg = args[index];
29
+ if (arg === '--help' || arg === '-h') {
30
+ console.log('Usage: typeroll init [directory] [--update] [--recipes] [--client none|claude|cursor|vscode]\nCreates an agent-neutral workspace. No network calls or remote writes. Updates preserve edited files.');
201
31
  return 0;
202
32
  }
203
- else if (!dir && !arg.startsWith('-')) {
33
+ if (arg === '--update')
34
+ update = true;
35
+ else if (arg === '--recipes')
36
+ recipes = true;
37
+ else if (arg === '--client' && ['none', 'claude', 'cursor', 'vscode'].includes(args[index + 1] ?? ''))
38
+ client = args[++index];
39
+ else if (!arg.startsWith('-') && !hadDir) {
204
40
  dir = arg;
41
+ hadDir = true;
205
42
  }
206
43
  else {
207
- console.error(`typeroll-mcp init: unrecognised argument: ${arg}`);
208
- printHelp();
44
+ console.error(`Unsupported init argument: ${arg}. Use --help. --force is not supported; reconcile edited files explicitly.`);
209
45
  return 1;
210
46
  }
211
47
  }
212
- const target = dir ?? '.';
213
48
  try {
214
- const result = await runInit({ dir: target, force });
215
- const skillsTotal = result.skills.copied.length + result.skills.skipped.length;
216
- console.log(`typeroll-mcp: initialised project at ${result.dir}`);
217
- console.log('');
218
- console.log(` skills .claude/skills/ (${result.skills.copied.length} copied, ${result.skills.skipped.length} kept of ${skillsTotal})`);
219
- for (const f of result.files) {
220
- const verb = f.action === 'created' ? 'wrote' : f.action === 'merged' ? 'merged' : 'kept';
221
- console.log(` ${verb.padEnd(9)} ${f.path}`);
222
- }
223
- console.log('');
224
- console.log('Next steps:');
225
- console.log(' 1. Create an API key in the Typeroll portal (Settings → API keys).');
226
- console.log(' 2. Edit .mcp.json: set TYPEROLL_API_KEY and TYPEROLL_SITE_ID.');
227
- console.log(' 3. (Optional) cp .env.example .env and add image-provider keys.');
228
- console.log(' 4. Open this folder in Claude Code — it picks up .mcp.json + .claude/skills/.');
229
- console.log(' 5. Ask the agent to "connect to Typeroll and confirm the key works".');
230
- if (result.files.some((f) => f.action === 'skipped')) {
231
- console.log('');
232
- console.log('Some files already existed and were kept — rerun with --force to overwrite.');
233
- }
49
+ const result = await runInit({ dir, update, recipes, client });
50
+ console.log(JSON.stringify(result, null, 2));
51
+ console.log('Next: fill brief/, connect hosted MCP at https://app.typeroll.com/api/mcp?tools=compact, create/select your Site, record its IDs/Version in typeroll.json, then run typeroll doctor. Local MCP uses an injected TYPEROLL_API_KEY; no key is stored by init. See README.md.');
234
52
  return 0;
235
53
  }
236
- catch (e) {
237
- const reason = e instanceof Error ? e.message : String(e);
238
- console.error(`typeroll-mcp init: ${reason}`);
54
+ catch {
55
+ console.error('Workspace could not be created/updated. Check file permissions, symlinks and typeroll.lock.json. Existing edited files are not overwritten.');
239
56
  return 1;
240
57
  }
241
58
  }
242
- function printHelp() {
243
- console.error('');
244
- console.error('Usage: npx @typeroll/mcp-server init [directory] [--force]');
245
- console.error('');
246
- console.error('Bootstraps a local project for agent-driven Typeroll work:');
247
- console.error(' - copies the bundled skills to .claude/skills/');
248
- console.error(' - writes/merges .mcp.json with a typeroll server entry');
249
- console.error(' - writes an AGENTS.md pointer, .env.example, images/lab/.gitignore');
250
- console.error('');
251
- console.error('Directory defaults to the current folder. Idempotent: rerunning');
252
- console.error('never clobbers your edits, and existing .mcp.json values are kept.');
253
- console.error('');
254
- console.error('Options:');
255
- console.error(' --force, -f Overwrite existing files (replaces the typeroll .mcp.json entry)');
256
- console.error(' --help, -h Show this help');
257
- }
@@ -123,8 +123,8 @@ function printHelp() {
123
123
  console.error('Copies bundled Typeroll skill markdown files to the destination directory.');
124
124
  console.error('');
125
125
  console.error('Common destinations:');
126
- console.error(' .claude/skills Project-scoped (recommended)');
127
- console.error(' ~/.claude/skills User-scoped (available in every project)');
126
+ console.error(' ./typeroll-skills Neutral recipe directory');
127
+ console.error('For managed updates use: typeroll init . --recipes --update');
128
128
  console.error('');
129
129
  console.error('Options:');
130
130
  console.error(' --force, -f Overwrite files that already exist');
package/dist/server.js CHANGED
@@ -34,6 +34,10 @@ import { extensionTools } from './tools/extensions.js';
34
34
  import { skillTools } from './tools/skills.js';
35
35
  import { fail } from './tools/helpers.js';
36
36
  import { VERSION } from './version.js';
37
+ import { compactTools, COMPACT_INSTRUCTIONS } from './compact-tools.js';
38
+ /** What permission a tool needs to run. Maps onto the share model: a
39
+ * read-shared site can `read`-list but cannot mutate. Mode `write` is the
40
+ * default — applied to anything that isn't trivially side-effect-free. */
37
41
  const PERM_RANK = { read: 0, write: 1, admin: 2 };
38
42
  /**
39
43
  * Classify a tool by name into the minimum permission needed. We keep this
@@ -52,7 +56,8 @@ function effectFor(name) {
52
56
  || name === 'update_app'
53
57
  || name === 'list_extension_installations'
54
58
  || name === 'read_extension_installation'
55
- || name === 'update_extension_installation_config')
59
+ || name === 'update_extension_installation_config'
60
+ || name === 'activate_extension_release')
56
61
  return 'admin';
57
62
  if (name.startsWith('list_') ||
58
63
  name.startsWith('read_') ||
@@ -121,7 +126,7 @@ export function buildServer(options) {
121
126
  }
122
127
  const server = new McpServer(options.info ?? DEFAULT_INFO, {
123
128
  capabilities: { tools: {} },
124
- instructions: SERVER_INSTRUCTIONS,
129
+ instructions: options.toolMode === 'compact' ? COMPACT_INSTRUCTIONS : SERVER_INSTRUCTIONS,
125
130
  });
126
131
  const allTools = [
127
132
  ...skillTools,
@@ -154,6 +159,7 @@ export function buildServer(options) {
154
159
  const register = server.registerTool.bind(server);
155
160
  const isMultiSite = !!options.allowedSites;
156
161
  const allowedById = new Map((options.allowedSites ?? []).map((s) => [s.siteId, s]));
162
+ const catalog = [];
157
163
  for (const tool of allTools) {
158
164
  const effect = effectFor(tool.name);
159
165
  // Skill-discovery tools (and any future site-less tool) operate without a
@@ -166,43 +172,47 @@ export function buildServer(options) {
166
172
  const siteIdField = {
167
173
  site_id: z
168
174
  .string()
169
- .describe(`Required. The id of the site this call targets. Use list_sites to discover ids; available sites for this connection: ${(options.allowedSites ?? []).map((s) => s.siteId).join(', ') || '(none)'}.`),
175
+ .describe('Required. The Site ID this call targets. Use list_sites to discover accessible sites.'),
170
176
  };
171
177
  schema = { ...(tool.inputSchema ?? {}), ...siteIdField };
172
178
  }
173
- register(tool.name, {
174
- description: tool.description,
175
- ...(schema ? { inputSchema: schema } : {}),
176
- },
177
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
178
- async (args) => {
179
- const rawArgs = args ?? {};
180
- let siteId;
181
- if (isMultiSite && needsSite) {
182
- const provided = typeof rawArgs.site_id === 'string' ? rawArgs.site_id.trim() : '';
183
- if (!provided) {
184
- return fail(new Error('site_id is required. This connector covers multiple sites; pick one from list_sites.'));
185
- }
186
- const match = allowedById.get(provided);
187
- if (!match) {
188
- return fail(new Error(`site_id "${provided}" is not accessible by this connection. Use list_sites to see allowed ids.`));
179
+ catalog.push({ name: tool.name, description: tool.description, effect, schema: schema ?? {}, invoke:
180
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
181
+ async (args) => {
182
+ const rawArgs = { ...(args ?? {}) };
183
+ let siteId;
184
+ if (isMultiSite && needsSite) {
185
+ const provided = typeof rawArgs.site_id === 'string' ? rawArgs.site_id.trim() : '';
186
+ if (!provided) {
187
+ return fail(new Error('site_id is required. This connector covers multiple sites; pick one from list_sites.'));
188
+ }
189
+ const match = allowedById.get(provided);
190
+ if (!match) {
191
+ return fail(new Error(`site_id "${provided}" is not accessible by this connection. Use list_sites to see allowed ids.`));
192
+ }
193
+ if (PERM_RANK[match.permission] < PERM_RANK[effect]) {
194
+ return fail(new Error(`Tool "${tool.name}" requires ${effect} permission on site "${provided}"; this connection has ${match.permission}.`));
195
+ }
196
+ siteId = provided;
197
+ // Strip site_id from the args we pass downstream so individual
198
+ // tool schemas (which don't declare it) don't reject the call.
199
+ delete rawArgs.site_id;
189
200
  }
190
- if (PERM_RANK[match.permission] < PERM_RANK[effect]) {
191
- return fail(new Error(`Tool "${tool.name}" requires ${effect} permission on site "${provided}"; this connection has ${match.permission}.`));
201
+ else {
202
+ // Single-site mode, or a site-less tool in multi-site mode (no
203
+ // fixedSiteId) — the latter's handler ignores siteId entirely.
204
+ siteId = options.fixedSiteId ?? '';
192
205
  }
193
- siteId = provided;
194
- // Strip site_id from the args we pass downstream so individual
195
- // tool schemas (which don't declare it) don't reject the call.
196
- delete rawArgs.site_id;
197
- }
198
- else {
199
- // Single-site mode, or a site-less tool in multi-site mode (no
200
- // fixedSiteId) — the latter's handler ignores siteId entirely.
201
- siteId = options.fixedSiteId ?? '';
202
- }
203
- const deps = { client: options.client, siteId };
204
- return tool.handler(rawArgs, deps);
206
+ const deps = { client: options.client, siteId };
207
+ return tool.handler(rawArgs, deps);
208
+ },
205
209
  });
206
210
  }
211
+ for (const tool of options.toolMode === 'compact' ? compactTools(catalog) : catalog) {
212
+ register(tool.name, {
213
+ description: tool.description, inputSchema: tool.schema,
214
+ annotations: { readOnlyHint: tool.effect === 'read', destructiveHint: tool.effect !== 'read', openWorldHint: true },
215
+ }, tool.invoke);
216
+ }
207
217
  return server;
208
218
  }
@@ -31,6 +31,20 @@ export const extensionTools = [
31
31
  return ok(await client.get(siteId, `extensions/${encodeURIComponent(args.installation_id)}`));
32
32
  }),
33
33
  },
34
+ {
35
+ name: 'activate_extension_release',
36
+ description: 'Explicitly activate a pending migration release for this site only. First read_extension_installation and review pending_activation_manifest, configuration and app migration instructions. Changes runtime selection immediately; never queues publication. Existing permissions remain unless granted_scopes is explicitly supplied. Admin permission required.',
37
+ inputSchema: {
38
+ installation_id: z.string().min(1),
39
+ version: z.string().min(1).describe('Pending published release to activate.'),
40
+ config: z.record(z.unknown()).optional(),
41
+ granted_scopes: z.array(z.string()).optional(),
42
+ },
43
+ handler: withErrorBoundary(async (args, { client, siteId }) => {
44
+ const { installation_id, ...patch } = args;
45
+ return ok(await client.patch(siteId, `extensions/${encodeURIComponent(installation_id)}`, patch));
46
+ }),
47
+ },
34
48
  {
35
49
  name: 'update_extension_installation_config',
36
50
  description: 'Update schema-defined config for an installed Extension. Call read_extension_installation first and send only keys declared by manifest.config_schema. Omitted fields preserve their current values, including masked secrets. This can update public content such as consent text, policy-link text, and policy URLs. A production deploy is queued by default; pass deploy:false only when batching changes and deploy later. Admin permission required.',
@@ -191,13 +191,14 @@ export const pageTools = [
191
191
  },
192
192
  {
193
193
  name: 'batch_update_pages',
194
- description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch, save? }; failures are reported per-row, the rest still apply. BUFFER MODEL: content patches land in each page\'s unsaved draft; per-entry save:true (or the top-level save flag) commits — typical for a user-approved sweep. content_mode is rejected per-row; switch each page with set_page_mode.',
194
+ description: 'Apply per-page patches in one call (up to 200 entries). Each entry is { page_id, patch, save?, answer_sources? }; failures are reported per-row, the rest still apply. BUFFER MODEL: content patches land in each page\'s unsaved draft; per-entry save:true (or the top-level save flag) commits — typical for a user-approved sweep. content_mode is rejected per-row; switch each page with set_page_mode.',
195
195
  inputSchema: {
196
196
  updates: z
197
197
  .array(z.object({
198
198
  page_id: z.string(),
199
199
  patch: z.record(z.unknown()),
200
200
  save: z.boolean().optional(),
201
+ answer_sources: answerSources,
201
202
  }))
202
203
  .min(1)
203
204
  .max(200),
@@ -2,6 +2,16 @@
2
2
  import { z } from 'zod';
3
3
  import { ok, withErrorBoundary } from './helpers.js';
4
4
  export const siteTools = [
5
+ {
6
+ name: 'list_sites', noSite: true,
7
+ description: 'List accessible Sites without changing them. Use the returned IDs for site_id or your workspace binding. Results are paginated locally.',
8
+ inputSchema: { offset: z.number().int().min(0).default(0), limit: z.number().int().min(1).max(50).default(20) },
9
+ handler: withErrorBoundary(async (args, { client }) => {
10
+ const response = await client.rootGet('sites');
11
+ const sites = response.sites ?? [];
12
+ return ok({ sites: sites.slice(args.offset, args.offset + args.limit), total: sites.length, next_offset: args.offset + args.limit < sites.length ? args.offset + args.limit : null });
13
+ }),
14
+ },
5
15
  {
6
16
  name: 'create_site',
7
17
  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.",