@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/AGENTS.md +48 -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 +43 -33
- package/dist/tools/extensions.js +14 -0
- package/dist/tools/pages.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-migration-evidence.md +53 -0
- package/skills/tr-new-site.md +20 -4
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
|
|
@@ -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(
|
|
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
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
}
|
package/dist/tools/extensions.js
CHANGED
|
@@ -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.',
|
package/dist/tools/pages.js
CHANGED
|
@@ -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),
|
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.",
|