@typeroll/mcp-server 0.45.22 → 0.45.24
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 +18 -0
- package/README.md +26 -11
- package/dist/bundled-content.js +4 -4
- package/dist/client.js +4 -0
- package/dist/compact-tools.js +40 -0
- package/dist/index.js +36 -1
- package/dist/init.js +40 -239
- package/dist/install-skills.js +2 -2
- package/dist/server.js +41 -32
- package/dist/tools/pages.js +3 -1
- package/dist/tools/settings.js +2 -1
- package/dist/tools/sites.js +10 -0
- package/dist/tools/skills.js +20 -2
- package/dist/version.js +1 -1
- package/dist/workspace.js +183 -0
- package/package.json +4 -3
- package/skills/README.md +18 -25
- package/skills/tr-new-site.md +20 -4
- package/skills/tr-responsive.md +22 -0
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] [
|
|
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 {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
195
|
-
|
|
196
|
-
if (arg === '--
|
|
197
|
-
|
|
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
|
-
|
|
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(`
|
|
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
|
|
215
|
-
|
|
216
|
-
console.log(
|
|
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
|
|
237
|
-
|
|
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
|
-
}
|
package/dist/install-skills.js
CHANGED
|
@@ -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('
|
|
127
|
-
console.error('
|
|
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
|
|
@@ -122,7 +126,7 @@ export function buildServer(options) {
|
|
|
122
126
|
}
|
|
123
127
|
const server = new McpServer(options.info ?? DEFAULT_INFO, {
|
|
124
128
|
capabilities: { tools: {} },
|
|
125
|
-
instructions: SERVER_INSTRUCTIONS,
|
|
129
|
+
instructions: options.toolMode === 'compact' ? COMPACT_INSTRUCTIONS : SERVER_INSTRUCTIONS,
|
|
126
130
|
});
|
|
127
131
|
const allTools = [
|
|
128
132
|
...skillTools,
|
|
@@ -155,6 +159,7 @@ export function buildServer(options) {
|
|
|
155
159
|
const register = server.registerTool.bind(server);
|
|
156
160
|
const isMultiSite = !!options.allowedSites;
|
|
157
161
|
const allowedById = new Map((options.allowedSites ?? []).map((s) => [s.siteId, s]));
|
|
162
|
+
const catalog = [];
|
|
158
163
|
for (const tool of allTools) {
|
|
159
164
|
const effect = effectFor(tool.name);
|
|
160
165
|
// Skill-discovery tools (and any future site-less tool) operate without a
|
|
@@ -167,43 +172,47 @@ export function buildServer(options) {
|
|
|
167
172
|
const siteIdField = {
|
|
168
173
|
site_id: z
|
|
169
174
|
.string()
|
|
170
|
-
.describe(
|
|
175
|
+
.describe('Required. The Site ID this call targets. Use list_sites to discover accessible sites.'),
|
|
171
176
|
};
|
|
172
177
|
schema = { ...(tool.inputSchema ?? {}), ...siteIdField };
|
|
173
178
|
}
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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;
|
|
190
200
|
}
|
|
191
|
-
|
|
192
|
-
|
|
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 ?? '';
|
|
193
205
|
}
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
delete rawArgs.site_id;
|
|
198
|
-
}
|
|
199
|
-
else {
|
|
200
|
-
// Single-site mode, or a site-less tool in multi-site mode (no
|
|
201
|
-
// fixedSiteId) — the latter's handler ignores siteId entirely.
|
|
202
|
-
siteId = options.fixedSiteId ?? '';
|
|
203
|
-
}
|
|
204
|
-
const deps = { client: options.client, siteId };
|
|
205
|
-
return tool.handler(rawArgs, deps);
|
|
206
|
+
const deps = { client: options.client, siteId };
|
|
207
|
+
return tool.handler(rawArgs, deps);
|
|
208
|
+
},
|
|
206
209
|
});
|
|
207
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
|
+
}
|
|
208
217
|
return server;
|
|
209
218
|
}
|
package/dist/tools/pages.js
CHANGED
|
@@ -91,6 +91,7 @@ export const pageTools = [
|
|
|
91
91
|
html_content: z.string().optional().describe('Body HTML — only used when content_mode="html".'),
|
|
92
92
|
blocks: z.array(z.any()).optional().describe('Block tree — only used when content_mode="blocks". Omit to get the default heading+prose seed.'),
|
|
93
93
|
status: z.enum(['draft', 'review', 'unlisted', 'published']).optional(),
|
|
94
|
+
breadcrumb_label: z.string().max(200).nullable().optional().describe('Optional short breadcrumb label. Leaves Page title, SEO title and route unchanged; null/empty uses the title.'),
|
|
94
95
|
seo_title: z.string().optional(),
|
|
95
96
|
append_seo_suffix: z.boolean().optional().describe('Set false to omit the site default SEO suffix on this page.'),
|
|
96
97
|
seo_description: z.string().optional(),
|
|
@@ -117,7 +118,7 @@ export const pageTools = [
|
|
|
117
118
|
},
|
|
118
119
|
{
|
|
119
120
|
name: 'update_page',
|
|
120
|
-
description: 'Shallow-merge update on a page (only the fields you pass change). BUFFER MODEL: content fields land in the page\'s unsaved DRAFT (working copy) — invisible to deploys and default previews until saved; `status`/`date_published` apply immediately. Pass save:true to commit in the same call, or commit_working_copy later after the user approves. Returns the draft view of the page. For "replace this page entirely", use replace_page. This tool does not accept content_mode; switch modes with `set_page_mode`.',
|
|
121
|
+
description: 'Shallow-merge update on a page, including its short breadcrumb label (only the fields you pass change). BUFFER MODEL: content fields land in the page\'s unsaved DRAFT (working copy) — invisible to deploys and default previews until saved; `status`/`date_published` apply immediately. Pass save:true to commit in the same call, or commit_working_copy later after the user approves. Returns the draft view of the page. For "replace this page entirely", use replace_page. This tool does not accept content_mode; switch modes with `set_page_mode`.',
|
|
121
122
|
inputSchema: {
|
|
122
123
|
page_id: z.string(),
|
|
123
124
|
patch: z
|
|
@@ -133,6 +134,7 @@ export const pageTools = [
|
|
|
133
134
|
kind: z.enum(['page', 'article']).optional(),
|
|
134
135
|
author: z.string().optional(),
|
|
135
136
|
language: z.string().optional(),
|
|
137
|
+
breadcrumb_label: z.string().max(200).nullable().optional().describe('Optional short breadcrumb label. Leaves Page title, SEO title and route unchanged; null/empty uses the title.'),
|
|
136
138
|
seo_title: z.string().optional(),
|
|
137
139
|
append_seo_suffix: z.boolean().optional().describe('Set false to use seo_title/page title verbatim without the site suffix.'),
|
|
138
140
|
seo_description: z.string().optional(),
|
package/dist/tools/settings.js
CHANGED
|
@@ -24,7 +24,7 @@ export const settingsTools = [
|
|
|
24
24
|
},
|
|
25
25
|
{
|
|
26
26
|
name: 'update_site_settings',
|
|
27
|
-
description: 'Patch site settings. Only the fields you pass change. ' +
|
|
27
|
+
description: 'Patch site settings, including responsive breakpoints for block layouts and visibility. Only the fields you pass change. ' +
|
|
28
28
|
'Pass fields at the TOP LEVEL of the object — do NOT wrap in a "settings" key. ' +
|
|
29
29
|
'Example: {"site_name": "Acme", "colors": {"primary": "#ff0"}} not {"settings": {...}}. ' +
|
|
30
30
|
'Nested objects (colors, fonts, contact, social) are shallow-merged into the existing value. ' +
|
|
@@ -33,6 +33,7 @@ export const settingsTools = [
|
|
|
33
33
|
'Pass `version` to scope the write to a branch (copy-on-write) instead of main — the right way to brand/recolor a site inside a redesign branch (colors, fonts, logo, custom_css) without touching the live settings. Omit it to write main.',
|
|
34
34
|
inputSchema: {
|
|
35
35
|
version: versionParam,
|
|
36
|
+
responsive_breakpoints: z.object({ tablet: z.number().int().min(320).max(2560), laptop: z.number().int().min(320).max(2560), desktop: z.number().int().min(320).max(2560), wide: z.number().int().min(320).max(2560) }).strict().nullable().optional().describe('Increasing viewport widths for authored responsive block fields and hidden_on. All four required; null restores defaults. Menu collapse remains explicit.'),
|
|
36
37
|
site_name: z.string().optional(),
|
|
37
38
|
tagline: z.string().optional(),
|
|
38
39
|
logo: z.string().optional(),
|
package/dist/tools/sites.js
CHANGED
|
@@ -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.",
|