@avocadostudio-ai/mcp-server 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -0
- package/README.md +128 -0
- package/dist/capabilities.d.ts +63 -0
- package/dist/capabilities.js +116 -0
- package/dist/config.d.ts +15 -0
- package/dist/config.js +17 -0
- package/dist/http-auth.d.ts +14 -0
- package/dist/http-auth.js +26 -0
- package/dist/http.d.ts +13 -0
- package/dist/http.js +132 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +32 -0
- package/dist/orchestrator-client.d.ts +93 -0
- package/dist/orchestrator-client.js +64 -0
- package/dist/tools/_helpers.d.ts +33 -0
- package/dist/tools/_helpers.js +58 -0
- package/dist/tools/blocks.d.ts +4 -0
- package/dist/tools/blocks.js +256 -0
- package/dist/tools/chat.d.ts +3 -0
- package/dist/tools/chat.js +52 -0
- package/dist/tools/discovery.d.ts +4 -0
- package/dist/tools/discovery.js +105 -0
- package/dist/tools/history.d.ts +3 -0
- package/dist/tools/history.js +37 -0
- package/dist/tools/index.d.ts +10 -0
- package/dist/tools/index.js +28 -0
- package/dist/tools/media.d.ts +3 -0
- package/dist/tools/media.js +84 -0
- package/dist/tools/pages.d.ts +4 -0
- package/dist/tools/pages.js +131 -0
- package/dist/tools/preview.d.ts +3 -0
- package/dist/tools/preview.js +32 -0
- package/dist/tools/publishing.d.ts +3 -0
- package/dist/tools/publishing.js +67 -0
- package/dist/tools/sessions.d.ts +8 -0
- package/dist/tools/sessions.js +39 -0
- package/dist/tools/sites.d.ts +3 -0
- package/dist/tools/sites.js +90 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +16 -0
- package/package.json +68 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { jsonResult, errorResult } from "./_helpers.js";
|
|
3
|
+
const pageSlug = z.string().min(1).describe("Page slug the block lives on.");
|
|
4
|
+
const blockId = z.string().min(1).describe("Block instance id (from avocado-get-page).");
|
|
5
|
+
const listKey = z.string().min(1).describe("List-field name on the block (e.g. 'features', 'items', 'cards').");
|
|
6
|
+
const itemId = z.string().min(1).optional().describe("Stable id of the target list item (from avocado-get-page). Preferred over index — survives sibling inserts/removals.");
|
|
7
|
+
const afterItemId = z.string().min(1).optional().describe("Place after the item with this id (preferred over afterIndex). Omit to append/move to top.");
|
|
8
|
+
export function registerBlockTools(server, client, gate) {
|
|
9
|
+
server.tool("avocado-batch-apply", [
|
|
10
|
+
"Apply an array of ops in a single atomic transaction. All ops commit together or none do, and `previewVersion` only bumps once for the whole batch. Use for multi-op flows (e.g. translating every field on a page, bulk list-item edits) instead of chaining single-op tools — avoids N round-trips and N version bumps.",
|
|
11
|
+
"",
|
|
12
|
+
"IMPORTANT: batch ops use the wire-format Operation schema, NOT the single-op MCP tool parameter names. Field names differ: single-op tools like `avocado-rename-page` take `slug`/`newSlug` and `avocado-update-page-meta` takes flat `title`/`description`/`ogImage`, but their batch equivalents take `pageSlug`/`newPageSlug` and `patch: { title?, description?, ogImage? }` respectively. Tip: if you're unsure about an op's exact shape, probe with a minimal 1–2 op batch before constructing a large payload.",
|
|
13
|
+
"",
|
|
14
|
+
"Required fields per op type:",
|
|
15
|
+
"- { op: 'create_page', page: { id, slug, title, updatedAt, blocks: [{id,type,props}], meta? } }",
|
|
16
|
+
"- { op: 'add_block', pageSlug, block: { id, type, props }, afterBlockId? }",
|
|
17
|
+
"- { op: 'update_props', pageSlug, blockId, patch }",
|
|
18
|
+
"- { op: 'remove_block', pageSlug, blockId }",
|
|
19
|
+
"- { op: 'move_block', pageSlug, blockId, afterBlockId? }",
|
|
20
|
+
"- { op: 'duplicate_block', pageSlug, blockId, toPageSlug?, newBlockId?, afterBlockId? }",
|
|
21
|
+
"- { op: 'add_item', pageSlug, blockId, listKey, item, afterItemId? | afterIndex? } — the new item gets a stable id automatically; omit the anchor to append.",
|
|
22
|
+
"- { op: 'update_item', pageSlug, blockId, listKey, patch, itemId? | index? } — target by itemId (preferred, robust across the batch) or index.",
|
|
23
|
+
"- { op: 'remove_item', pageSlug, blockId, listKey, itemId? | index? }",
|
|
24
|
+
"- { op: 'move_item', pageSlug, blockId, listKey, itemId? | index?, afterItemId? | afterIndex? } — omit the anchor to move to front.",
|
|
25
|
+
"- { op: 'reorder_items', pageSlug, blockId, listKey, order } — order is the FINAL sequence covering every item exactly once (item ids, or 0-based indexes for id-less lists like Table rows). Prefer over chained move_item when several items change position.",
|
|
26
|
+
"- { op: 'reorder_blocks', pageSlug, order } — order is the FINAL sequence of every non-chrome block id on the page (omit pinned SiteHeader/Footer). Prefer over chained move_block.",
|
|
27
|
+
"- { op: 'rename_page', pageSlug, newPageSlug?, newTitle? } — provide at least one of newPageSlug (different from current) or newTitle (different from current). Omit newPageSlug for title-only rename.",
|
|
28
|
+
"- { op: 'remove_page', pageSlug }",
|
|
29
|
+
"- { op: 'move_page', pageSlug, afterPageSlug? }",
|
|
30
|
+
"- { op: 'duplicate_page', pageSlug, newPageSlug?, newTitle?, afterPageSlug? }",
|
|
31
|
+
"- { op: 'update_page_meta', pageSlug, patch: { title?, description?, ogImage? } } — patch must contain at least one field whose value differs from the current meta.",
|
|
32
|
+
"- { op: 'update_site_config', patch: { name?, logo?, navLabels?, navGroups? } }",
|
|
33
|
+
"- { op: 'update_theme', patch: { brandColor?, accentColor?, backgroundColor?, surfaceColor?, headingColor?, textColor?, mutedTextColor?, headingFont?, bodyFont?, radius? }, cssVars? } — site-wide colors/fonts/radius. patch uses semantic tokens (CSS color/font/length strings); set a token to \"\" to clear it. cssVars is a raw --var→value escape hatch (merged last).",
|
|
34
|
+
"",
|
|
35
|
+
"The whole call rejects atomically if any op fails validation. For a multi-step edit where one op is finicky, consider running the reliable bulk as a batch and the finicky one as a follow-up single-op call so a rejection on the hard op doesn't roll back the good work.",
|
|
36
|
+
].join("\n"), {
|
|
37
|
+
ops: z
|
|
38
|
+
.array(z.record(z.string(), z.unknown()))
|
|
39
|
+
.min(1)
|
|
40
|
+
.describe("Array of ops to apply atomically. Each must include an `op` discriminator and the wire-format fields required for that op type (see tool description for the per-op shape list)."),
|
|
41
|
+
}, async ({ ops }) => {
|
|
42
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
43
|
+
return errorResult(new Error("ops must be a non-empty array."));
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
return jsonResult(await client.applyOps(ops));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return errorResult(err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
server.tool("avocado-dry-run-ops", [
|
|
53
|
+
"Validate a hand-built ops array WITHOUT mutating draft state. Returns per-op results (`opResults`: applied | skipped | failed, with a reason on each) and a structured before→after `preview` diff (changed pages/blocks/fields). Unlike avocado-batch-apply, a failing op does not abort the others — it's reported as `failed`, so you get a full 'what would happen' map in one call.",
|
|
54
|
+
"Use this to check a finicky plan before committing, or to discover which op in a batch is the problem. Ops use the same wire format as avocado-batch-apply (see that tool's description for per-op shapes).",
|
|
55
|
+
].join("\n"), {
|
|
56
|
+
ops: z
|
|
57
|
+
.array(z.record(z.string(), z.unknown()))
|
|
58
|
+
.min(1)
|
|
59
|
+
.describe("Array of ops to validate. Same wire format as avocado-batch-apply."),
|
|
60
|
+
}, async ({ ops }) => {
|
|
61
|
+
if (!Array.isArray(ops) || ops.length === 0) {
|
|
62
|
+
return errorResult(new Error("ops must be a non-empty array."));
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return jsonResult(await client.request("POST", "/ops", {
|
|
66
|
+
body: client.scopedBody({ ops, dryRun: true }),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
return errorResult(err);
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
const _gated_0 = server.tool("avocado-add-block", "Insert a new block into a page. Call avocado-get-block-schema first to learn the required prop shape for the block type.", {
|
|
74
|
+
pageSlug,
|
|
75
|
+
block: z.object({
|
|
76
|
+
id: z.string().min(1),
|
|
77
|
+
type: z.string().min(1),
|
|
78
|
+
props: z.record(z.string(), z.unknown()),
|
|
79
|
+
}),
|
|
80
|
+
afterBlockId: z.string().min(1).optional().describe("Insert after this block id. Omit to append at the end."),
|
|
81
|
+
}, async ({ pageSlug, block, afterBlockId }) => {
|
|
82
|
+
const refusal = gate?.refuse("structuralEdits");
|
|
83
|
+
if (refusal)
|
|
84
|
+
return errorResult(refusal);
|
|
85
|
+
try {
|
|
86
|
+
return jsonResult(await client.applyOps([{ op: "add_block", pageSlug, block, afterBlockId }]));
|
|
87
|
+
}
|
|
88
|
+
catch (err) {
|
|
89
|
+
return errorResult(err);
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
gate?.track("structuralEdits", _gated_0);
|
|
93
|
+
server.tool("avocado-update-block-props", "Patch one or more props on an existing block. Only the keys in `patch` are updated; other props remain untouched.", {
|
|
94
|
+
pageSlug,
|
|
95
|
+
blockId,
|
|
96
|
+
patch: z.record(z.string(), z.unknown()).describe("Partial props object. Keys must be valid for the block type."),
|
|
97
|
+
}, async ({ pageSlug, blockId, patch }) => {
|
|
98
|
+
try {
|
|
99
|
+
return jsonResult(await client.applyOps([{ op: "update_props", pageSlug, blockId, patch }]));
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return errorResult(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
const _gated_1 = server.tool("avocado-remove-block", "Delete a block from a page. Undo-able from the editor history.", { pageSlug, blockId }, async ({ pageSlug, blockId }) => {
|
|
106
|
+
const refusal = gate?.refuse("structuralEdits");
|
|
107
|
+
if (refusal)
|
|
108
|
+
return errorResult(refusal);
|
|
109
|
+
try {
|
|
110
|
+
return jsonResult(await client.applyOps([{ op: "remove_block", pageSlug, blockId }]));
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
return errorResult(err);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
gate?.track("structuralEdits", _gated_1);
|
|
117
|
+
const _gated_2 = server.tool("avocado-move-block", "Reorder a block within its page. Pass afterBlockId to place it after another block, or omit to move to the top.", {
|
|
118
|
+
pageSlug,
|
|
119
|
+
blockId,
|
|
120
|
+
afterBlockId: z.string().min(1).optional(),
|
|
121
|
+
}, async ({ pageSlug, blockId, afterBlockId }) => {
|
|
122
|
+
const refusal = gate?.refuse("structuralEdits");
|
|
123
|
+
if (refusal)
|
|
124
|
+
return errorResult(refusal);
|
|
125
|
+
try {
|
|
126
|
+
return jsonResult(await client.applyOps([{ op: "move_block", pageSlug, blockId, afterBlockId }]));
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
return errorResult(err);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
gate?.track("structuralEdits", _gated_2);
|
|
133
|
+
const _gated_3 = server.tool("avocado-duplicate-block", "Duplicate a block, optionally onto a different page and/or with an explicit new id.", {
|
|
134
|
+
pageSlug,
|
|
135
|
+
blockId,
|
|
136
|
+
toPageSlug: z.string().min(1).optional().describe("Target page slug (defaults to the source page)."),
|
|
137
|
+
newBlockId: z.string().min(1).optional(),
|
|
138
|
+
afterBlockId: z.string().min(1).optional(),
|
|
139
|
+
}, async ({ pageSlug, blockId, toPageSlug, newBlockId, afterBlockId }) => {
|
|
140
|
+
const refusal = gate?.refuse("structuralEdits");
|
|
141
|
+
if (refusal)
|
|
142
|
+
return errorResult(refusal);
|
|
143
|
+
try {
|
|
144
|
+
return jsonResult(await client.applyOps([
|
|
145
|
+
{ op: "duplicate_block", pageSlug, blockId, toPageSlug, newBlockId, afterBlockId },
|
|
146
|
+
]));
|
|
147
|
+
}
|
|
148
|
+
catch (err) {
|
|
149
|
+
return errorResult(err);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
gate?.track("structuralEdits", _gated_3);
|
|
153
|
+
// ── List-field item ops ──
|
|
154
|
+
server.tool("avocado-add-list-item", "Append or insert an item into a block's list field (e.g. add a feature to FeatureGrid, an item to FAQAccordion). The new item gets a stable id automatically.", {
|
|
155
|
+
pageSlug,
|
|
156
|
+
blockId,
|
|
157
|
+
listKey,
|
|
158
|
+
item: z.record(z.string(), z.unknown()),
|
|
159
|
+
afterItemId,
|
|
160
|
+
afterIndex: z.number().int().min(0).optional().describe("Insert after this index. Omit to append."),
|
|
161
|
+
}, async ({ pageSlug, blockId, listKey, item, afterItemId, afterIndex }) => {
|
|
162
|
+
try {
|
|
163
|
+
return jsonResult(await client.applyOps([
|
|
164
|
+
{ op: "add_item", pageSlug, blockId, listKey, item, afterItemId, afterIndex },
|
|
165
|
+
]));
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
return errorResult(err);
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
server.tool("avocado-update-list-item", "Patch fields on one item inside a block's list field. Identify it by itemId (preferred) or zero-based index.", {
|
|
172
|
+
pageSlug,
|
|
173
|
+
blockId,
|
|
174
|
+
listKey,
|
|
175
|
+
itemId,
|
|
176
|
+
index: z.number().int().min(0).optional().describe("Item index (0-based). Used only when itemId is omitted."),
|
|
177
|
+
patch: z.record(z.string(), z.unknown()),
|
|
178
|
+
}, async ({ pageSlug, blockId, listKey, itemId, index, patch }) => {
|
|
179
|
+
try {
|
|
180
|
+
return jsonResult(await client.applyOps([
|
|
181
|
+
{ op: "update_item", pageSlug, blockId, listKey, itemId, index, patch },
|
|
182
|
+
]));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
return errorResult(err);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
server.tool("avocado-remove-list-item", "Remove an item from a block's list field. Identify it by itemId (preferred) or zero-based index.", {
|
|
189
|
+
pageSlug,
|
|
190
|
+
blockId,
|
|
191
|
+
listKey,
|
|
192
|
+
itemId,
|
|
193
|
+
index: z.number().int().min(0).optional().describe("Item index to remove (0-based). Used only when itemId is omitted."),
|
|
194
|
+
}, async ({ pageSlug, blockId, listKey, itemId, index }) => {
|
|
195
|
+
try {
|
|
196
|
+
return jsonResult(await client.applyOps([
|
|
197
|
+
{ op: "remove_item", pageSlug, blockId, listKey, itemId, index },
|
|
198
|
+
]));
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
return errorResult(err);
|
|
202
|
+
}
|
|
203
|
+
});
|
|
204
|
+
server.tool("avocado-move-list-item", "Reorder an item inside a block's list field. Identify the item by itemId (preferred) or index; pass afterItemId/afterIndex to place it after another item, or omit to move to the top.", {
|
|
205
|
+
pageSlug,
|
|
206
|
+
blockId,
|
|
207
|
+
listKey,
|
|
208
|
+
itemId,
|
|
209
|
+
index: z.number().int().min(0).optional().describe("Current item index (0-based). Used only when itemId is omitted."),
|
|
210
|
+
afterItemId,
|
|
211
|
+
afterIndex: z.number().int().min(0).optional(),
|
|
212
|
+
}, async ({ pageSlug, blockId, listKey, itemId, index, afterItemId, afterIndex }) => {
|
|
213
|
+
try {
|
|
214
|
+
return jsonResult(await client.applyOps([
|
|
215
|
+
{ op: "move_item", pageSlug, blockId, listKey, itemId, index, afterItemId, afterIndex },
|
|
216
|
+
]));
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
return errorResult(err);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
server.tool("avocado-reorder-list-items", "Reorder ALL items of a block's list field in one atomic call by stating the final order. Prefer this over repeated avocado-move-list-item calls whenever more than one item changes position (sort, reverse, rearrange) — sequential moves use stale positions.", {
|
|
223
|
+
pageSlug,
|
|
224
|
+
blockId,
|
|
225
|
+
listKey,
|
|
226
|
+
order: z.array(z.union([z.string().min(1), z.number().int().min(0)])).min(1)
|
|
227
|
+
.describe("The FINAL order, covering every item exactly once. Entries are stable item ids (from avocado-get-page; preferred) or current 0-based indexes (for id-less lists like Table rows)."),
|
|
228
|
+
}, async ({ pageSlug, blockId, listKey, order }) => {
|
|
229
|
+
try {
|
|
230
|
+
return jsonResult(await client.applyOps([
|
|
231
|
+
{ op: "reorder_items", pageSlug, blockId, listKey, order },
|
|
232
|
+
]));
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return errorResult(err);
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
const _gated_4 = server.tool("avocado-reorder-blocks", "Reorder a page's sections in one atomic call by stating the final block order. Prefer this over repeated avocado-move-block calls whenever more than one block changes position. Omit chrome blocks (SiteHeader/Footer) — they are pinned.", {
|
|
239
|
+
pageSlug,
|
|
240
|
+
order: z.array(z.string().min(1)).min(1)
|
|
241
|
+
.describe("The FINAL order: every non-chrome block id on the page (from avocado-get-page), each exactly once."),
|
|
242
|
+
}, async ({ pageSlug, order }) => {
|
|
243
|
+
const refusal = gate?.refuse("structuralEdits");
|
|
244
|
+
if (refusal)
|
|
245
|
+
return errorResult(refusal);
|
|
246
|
+
try {
|
|
247
|
+
return jsonResult(await client.applyOps([
|
|
248
|
+
{ op: "reorder_blocks", pageSlug, order },
|
|
249
|
+
]));
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
return errorResult(err);
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
gate?.track("structuralEdits", _gated_4);
|
|
256
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { jsonResult, errorResult, chatResult } from "./_helpers.js";
|
|
3
|
+
const message = z.string().min(1).describe("Natural-language edit instruction.");
|
|
4
|
+
const slug = z.string().optional().describe("Focus the planner on a specific page (defaults to the home page).");
|
|
5
|
+
const locale = z.enum(["en", "de"]).optional().describe("Language for the planner's summary/change-log response.");
|
|
6
|
+
const activeBlockId = z.string().optional().describe("Hint: which block the user is currently focused on.");
|
|
7
|
+
const screenshot = z.boolean().optional().describe("Auto-attach a draft screenshot of the mutated page when ops applied. Defaults to true; pass false to skip.");
|
|
8
|
+
export function registerChatTools(server, client) {
|
|
9
|
+
server.tool("avocado-chat-plan", "High-level shortcut: send a natural-language instruction to the planner. The orchestrator decides whether to apply directly or surface a pending plan for approval. When ops apply, a draft screenshot of the touched page auto-attaches (disable with screenshot:false). Response may contain a `pendingPlanId` — if so, call avocado-approve-pending-plan or avocado-discard-pending-plan next.", { message, slug, locale, activeBlockId, screenshot }, async ({ screenshot: wantShot, ...args }) => {
|
|
10
|
+
try {
|
|
11
|
+
const res = await client.request("POST", "/chat", { body: client.scopedBody(args) });
|
|
12
|
+
return await chatResult(client, res, { screenshot: wantShot });
|
|
13
|
+
}
|
|
14
|
+
catch (err) {
|
|
15
|
+
return errorResult(err);
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
server.tool("avocado-preview-plan", "Run the planner in plan-only mode: the orchestrator returns the would-apply ops without mutating draft state. The response includes a `pendingPlanId` (pass to avocado-approve-pending-plan) and a `planPreview` — per-op results (applied/skipped/failed) plus a structured before→after diff (changed pages/blocks/fields) — so you can review exactly what the plan would change before approving. To dry-run a hand-built ops array instead of an instruction, use avocado-dry-run-ops.", { message, slug, locale, activeBlockId }, async (args) => {
|
|
19
|
+
try {
|
|
20
|
+
return jsonResult(await client.request("POST", "/chat", {
|
|
21
|
+
body: client.scopedBody({ ...args, executionMode: "plan_only" }),
|
|
22
|
+
}));
|
|
23
|
+
}
|
|
24
|
+
catch (err) {
|
|
25
|
+
return errorResult(err);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
server.tool("avocado-approve-pending-plan", "Apply the pending plan that's waiting for approval on the current session. Pass `pendingPlanId` from the prior chat response to protect against race conditions (the orchestrator rejects stale approvals). On successful apply, a draft screenshot of the touched page auto-attaches (disable with screenshot:false).", {
|
|
29
|
+
pendingPlanId: z.string().optional().describe("Id from the prior chat response's pendingPlanId field. Omit to apply whatever is pending (risky)."),
|
|
30
|
+
screenshot,
|
|
31
|
+
}, async ({ pendingPlanId, screenshot: wantShot }) => {
|
|
32
|
+
try {
|
|
33
|
+
const res = await client.request("POST", "/chat", {
|
|
34
|
+
body: client.scopedBody({ message: "approve", executionMode: "apply_pending_plan", pendingPlanId }),
|
|
35
|
+
});
|
|
36
|
+
return await chatResult(client, res, { screenshot: wantShot });
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return errorResult(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
server.tool("avocado-discard-pending-plan", "Discard the pending plan waiting for approval on the current session (user rejected the proposal). Clears orchestrator-side state.", {}, async () => {
|
|
43
|
+
try {
|
|
44
|
+
return jsonResult(await client.request("POST", "/chat", {
|
|
45
|
+
body: client.scopedBody({ message: "discard", executionMode: "discard_pending_plan" }),
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
return errorResult(err);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { OrchestratorClient } from "../orchestrator-client.ts";
|
|
3
|
+
/** Read-only tools that help agents discover what they can edit. */
|
|
4
|
+
export declare function registerDiscoveryTools(server: McpServer, client: OrchestratorClient): void;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { getAllBlockMeta, getBlockJsonSchema } from "@avocadostudio-ai/shared";
|
|
3
|
+
/**
|
|
4
|
+
* Fetch the target site's manifest, falling back to this process's registry.
|
|
5
|
+
*
|
|
6
|
+
* The fallback is *labelled* rather than silent. Quietly substituting the local
|
|
7
|
+
* built-ins is what the original bug did, and a confident wrong answer is worse
|
|
8
|
+
* than a hedged one: an agent that is told the list may not be the site's can
|
|
9
|
+
* check, while an agent that is told nothing cannot.
|
|
10
|
+
*/
|
|
11
|
+
async function loadManifest(client) {
|
|
12
|
+
try {
|
|
13
|
+
const manifest = await client.request("GET", "/blocks/manifest");
|
|
14
|
+
if (manifest && Array.isArray(manifest.blocks))
|
|
15
|
+
return { manifest, source: "orchestrator" };
|
|
16
|
+
throw new Error("manifest response had no blocks array");
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
20
|
+
return {
|
|
21
|
+
manifest: localRegistryManifest(),
|
|
22
|
+
source: "fallback:local-registry",
|
|
23
|
+
note: `Could not read the site's block manifest from ${client.config.orchestratorUrl}/blocks/manifest ` +
|
|
24
|
+
`(${reason}). What follows is the MCP server's own built-in registry, which is NOT ` +
|
|
25
|
+
`necessarily what this site can render — a site with custom blocks will have types not ` +
|
|
26
|
+
`listed here, and types listed here it cannot use. Verify before planning add_block ops.`
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** This process's registry, shaped like a manifest so both paths return one type. */
|
|
31
|
+
function localRegistryManifest() {
|
|
32
|
+
const meta = getAllBlockMeta();
|
|
33
|
+
return {
|
|
34
|
+
version: 1,
|
|
35
|
+
blocks: Object.entries(meta).flatMap(([type, m]) => {
|
|
36
|
+
const propsSchema = getBlockJsonSchema(type);
|
|
37
|
+
if (!propsSchema)
|
|
38
|
+
return [];
|
|
39
|
+
return [{
|
|
40
|
+
type,
|
|
41
|
+
displayName: m.displayName,
|
|
42
|
+
propsSchema,
|
|
43
|
+
...(m.category ? { category: m.category } : {}),
|
|
44
|
+
...(m.description ? { description: m.description } : {}),
|
|
45
|
+
...(m.chrome === true ? { chrome: true } : {}),
|
|
46
|
+
...(m.fields ? { fields: m.fields } : {}),
|
|
47
|
+
...(m.listFields ? { listFields: m.listFields } : {})
|
|
48
|
+
}];
|
|
49
|
+
})
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
/** Read-only tools that help agents discover what they can edit. */
|
|
53
|
+
export function registerDiscoveryTools(server, client) {
|
|
54
|
+
server.tool("avocado-list-block-types", "List every block type THIS SITE can render (name, category, description, whether it is structural chrome). Read from the orchestrator, so it includes the site's own custom blocks. Call this before constructing add-block ops so you know which types exist.", {}, async () => {
|
|
55
|
+
const { manifest, source, note } = await loadManifest(client);
|
|
56
|
+
const entries = manifest.blocks.map((b) => ({
|
|
57
|
+
type: b.type,
|
|
58
|
+
displayName: b.displayName ?? b.type,
|
|
59
|
+
category: b.category ?? null,
|
|
60
|
+
description: b.description ?? null,
|
|
61
|
+
chrome: b.chrome === true,
|
|
62
|
+
}));
|
|
63
|
+
return {
|
|
64
|
+
content: [{
|
|
65
|
+
type: "text",
|
|
66
|
+
text: JSON.stringify({ source, ...(note ? { warning: note } : {}), blockTypes: entries }, null, 2)
|
|
67
|
+
}]
|
|
68
|
+
};
|
|
69
|
+
});
|
|
70
|
+
server.tool("avocado-get-block-schema", "Return the JSON schema + field metadata for a block type on THIS SITE. Call this before add-block or update-block-props so you know the exact prop shape (required keys, enum options, list item fields, image aspect ratios).", {
|
|
71
|
+
type: z.string().describe("Block type name (e.g. 'Hero', 'CTA', 'FeatureGrid')."),
|
|
72
|
+
}, async ({ type }) => {
|
|
73
|
+
const { manifest, source, note } = await loadManifest(client);
|
|
74
|
+
const definition = manifest.blocks.find((b) => b.type === type);
|
|
75
|
+
if (!definition) {
|
|
76
|
+
const known = manifest.blocks.map((b) => b.type).join(", ");
|
|
77
|
+
return {
|
|
78
|
+
content: [{
|
|
79
|
+
type: "text",
|
|
80
|
+
text: `Unknown block type: ${type}.${note ? ` ${note}` : ""} Types available (${source}): ${known}`
|
|
81
|
+
}],
|
|
82
|
+
isError: true,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
/*
|
|
86
|
+
* `meta` is kept as its own key rather than flattened into the definition:
|
|
87
|
+
* the tool's documented response shape is { type, meta, jsonSchema }, and
|
|
88
|
+
* agents (and the phase2 tests) address it that way.
|
|
89
|
+
*/
|
|
90
|
+
const meta = {
|
|
91
|
+
displayName: definition.displayName ?? definition.type,
|
|
92
|
+
category: definition.category ?? null,
|
|
93
|
+
description: definition.description ?? null,
|
|
94
|
+
chrome: definition.chrome === true,
|
|
95
|
+
...(definition.fields ? { fields: definition.fields } : {}),
|
|
96
|
+
...(definition.listFields ? { listFields: definition.listFields } : {}),
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
content: [{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: JSON.stringify({ type, source, ...(note ? { warning: note } : {}), meta, jsonSchema: definition.propsSchema, defaultProps: definition.defaultProps ?? null }, null, 2),
|
|
102
|
+
}],
|
|
103
|
+
};
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { jsonResult, errorResult } from "./_helpers.js";
|
|
3
|
+
const slug = z.string().min(1).describe("Page slug to undo/redo (history is per-page).");
|
|
4
|
+
export function registerHistoryTools(server, client) {
|
|
5
|
+
server.tool("avocado-undo-edit", "Undo the last change on a page. Returns the new preview version and whether further undo/redo is available.", { slug }, async ({ slug }) => {
|
|
6
|
+
try {
|
|
7
|
+
return jsonResult(await client.request("POST", "/history/undo", {
|
|
8
|
+
body: client.scopedBody({ slug }),
|
|
9
|
+
}));
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
return errorResult(err);
|
|
13
|
+
}
|
|
14
|
+
});
|
|
15
|
+
server.tool("avocado-redo-edit", "Redo the most recently-undone change on a page.", { slug }, async ({ slug }) => {
|
|
16
|
+
try {
|
|
17
|
+
return jsonResult(await client.request("POST", "/history/redo", {
|
|
18
|
+
body: client.scopedBody({ slug }),
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
return errorResult(err);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
server.tool("avocado-restore-version", "Jump to a specific version number from the history log (does not consume undo/redo stacks). Current state is pushed to undo first.", {
|
|
26
|
+
targetVersion: z.number().int().positive().describe("Version number from the history log."),
|
|
27
|
+
}, async ({ targetVersion }) => {
|
|
28
|
+
try {
|
|
29
|
+
return jsonResult(await client.request("POST", "/history/restore", {
|
|
30
|
+
body: client.scopedBody({ targetVersion }),
|
|
31
|
+
}));
|
|
32
|
+
}
|
|
33
|
+
catch (err) {
|
|
34
|
+
return errorResult(err);
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { OrchestratorClient } from "../orchestrator-client.ts";
|
|
3
|
+
import type { CapabilityGate } from "../capabilities.ts";
|
|
4
|
+
/**
|
|
5
|
+
* `gate` is optional so a caller that has no capability answer yet — every
|
|
6
|
+
* caller, at startup — still registers the full tool set. The gate hides and
|
|
7
|
+
* refuses afterwards, once the site has said something. See ../capabilities.ts
|
|
8
|
+
* for why registration must never wait on that.
|
|
9
|
+
*/
|
|
10
|
+
export declare function registerAllTools(server: McpServer, client: OrchestratorClient, gate?: CapabilityGate): void;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { registerDiscoveryTools } from "./discovery.js";
|
|
2
|
+
import { registerPageTools } from "./pages.js";
|
|
3
|
+
import { registerBlockTools } from "./blocks.js";
|
|
4
|
+
import { registerSiteTools } from "./sites.js";
|
|
5
|
+
import { registerMediaTools } from "./media.js";
|
|
6
|
+
import { registerPublishingTools } from "./publishing.js";
|
|
7
|
+
import { registerHistoryTools } from "./history.js";
|
|
8
|
+
import { registerChatTools } from "./chat.js";
|
|
9
|
+
import { registerPreviewTools } from "./preview.js";
|
|
10
|
+
import { registerSessionTools } from "./sessions.js";
|
|
11
|
+
/**
|
|
12
|
+
* `gate` is optional so a caller that has no capability answer yet — every
|
|
13
|
+
* caller, at startup — still registers the full tool set. The gate hides and
|
|
14
|
+
* refuses afterwards, once the site has said something. See ../capabilities.ts
|
|
15
|
+
* for why registration must never wait on that.
|
|
16
|
+
*/
|
|
17
|
+
export function registerAllTools(server, client, gate) {
|
|
18
|
+
registerDiscoveryTools(server, client);
|
|
19
|
+
registerSessionTools(server, client, gate);
|
|
20
|
+
registerPageTools(server, client, gate);
|
|
21
|
+
registerBlockTools(server, client, gate);
|
|
22
|
+
registerSiteTools(server, client);
|
|
23
|
+
registerMediaTools(server, client);
|
|
24
|
+
registerPublishingTools(server, client);
|
|
25
|
+
registerHistoryTools(server, client);
|
|
26
|
+
registerChatTools(server, client);
|
|
27
|
+
registerPreviewTools(server, client);
|
|
28
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { jsonResult, errorResult } from "./_helpers.js";
|
|
3
|
+
/**
|
|
4
|
+
* Helper: orchestrator endpoints for upload/transcribe/interpret accept multipart only.
|
|
5
|
+
* MCP tool args are JSON, so we accept base64Data + mimeType and build a FormData in-process.
|
|
6
|
+
*/
|
|
7
|
+
function base64ToBlob(base64, mimeType) {
|
|
8
|
+
// Strip optional data URL prefix.
|
|
9
|
+
const data = base64.replace(/^data:[^;]+;base64,/, "");
|
|
10
|
+
const bytes = Buffer.from(data, "base64");
|
|
11
|
+
return new Blob([bytes], { type: mimeType });
|
|
12
|
+
}
|
|
13
|
+
export function registerMediaTools(server, client) {
|
|
14
|
+
server.tool("avocado-upload-image", "Upload an image to the orchestrator. Returns a URL usable as a block prop (Hero imageUrl, Card image, etc.). Accepts base64-encoded bytes + mime type.", {
|
|
15
|
+
base64Data: z.string().min(1).describe("Base64-encoded image bytes. Data URL prefix (data:image/...;base64,) is tolerated."),
|
|
16
|
+
mimeType: z.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]),
|
|
17
|
+
filename: z.string().optional().describe("Optional filename hint; defaults to 'upload.<ext>'."),
|
|
18
|
+
}, async ({ base64Data, mimeType, filename }) => {
|
|
19
|
+
try {
|
|
20
|
+
const form = new FormData();
|
|
21
|
+
const ext = mimeType.split("/")[1];
|
|
22
|
+
form.append("image", base64ToBlob(base64Data, mimeType), filename ?? `upload.${ext}`);
|
|
23
|
+
return jsonResult(await client.request("POST", "/image/upload", { formData: form }));
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
return errorResult(err);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
server.tool("avocado-generate-image", "Generate an image from a text prompt using the configured provider (OpenAI or Gemini). Returns a URL + alt text.", {
|
|
30
|
+
prompt: z.string().min(1).describe("Description of the image to generate."),
|
|
31
|
+
aspectRatio: z.enum(["landscape", "square", "portrait"]).optional(),
|
|
32
|
+
provider: z.enum(["openai", "gemini"]).optional().describe("Override the default image provider."),
|
|
33
|
+
model: z.string().optional().describe("Override the default model name."),
|
|
34
|
+
}, async (args) => {
|
|
35
|
+
try {
|
|
36
|
+
return jsonResult(await client.request("POST", "/image/generate", { body: args }));
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return errorResult(err);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
server.tool("avocado-search-unsplash", "Search Unsplash for stock photos matching a query. Returns an array of { imageUrl, thumbUrl, alt, author }.", {
|
|
43
|
+
q: z.string().min(1),
|
|
44
|
+
limit: z.number().int().min(1).max(20).optional(),
|
|
45
|
+
page: z.number().int().min(1).optional(),
|
|
46
|
+
}, async (args) => {
|
|
47
|
+
try {
|
|
48
|
+
return jsonResult(await client.request("GET", "/unsplash/search", { query: args }));
|
|
49
|
+
}
|
|
50
|
+
catch (err) {
|
|
51
|
+
return errorResult(err);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
server.tool("avocado-transcribe-audio", "Transcribe an audio clip to text via OpenAI Whisper. Accepts base64-encoded audio bytes.", {
|
|
55
|
+
base64Data: z.string().min(1),
|
|
56
|
+
mimeType: z.enum(["audio/mp3", "audio/mpeg", "audio/mp4", "audio/mpga", "audio/m4a", "audio/wav", "audio/webm"]),
|
|
57
|
+
filename: z.string().optional(),
|
|
58
|
+
}, async ({ base64Data, mimeType, filename }) => {
|
|
59
|
+
try {
|
|
60
|
+
const form = new FormData();
|
|
61
|
+
const ext = mimeType.split("/")[1];
|
|
62
|
+
form.append("audio", base64ToBlob(base64Data, mimeType), filename ?? `clip.${ext}`);
|
|
63
|
+
return jsonResult(await client.request("POST", "/audio/transcribe", { formData: form }));
|
|
64
|
+
}
|
|
65
|
+
catch (err) {
|
|
66
|
+
return errorResult(err);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
server.tool("avocado-interpret-image", "Run vision analysis on an image and return a one-sentence description (useful for alt text or screenshot-to-intent).", {
|
|
70
|
+
base64Data: z.string().min(1),
|
|
71
|
+
mimeType: z.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]),
|
|
72
|
+
filename: z.string().optional(),
|
|
73
|
+
}, async ({ base64Data, mimeType, filename }) => {
|
|
74
|
+
try {
|
|
75
|
+
const form = new FormData();
|
|
76
|
+
const ext = mimeType.split("/")[1];
|
|
77
|
+
form.append("image", base64ToBlob(base64Data, mimeType), filename ?? `image.${ext}`);
|
|
78
|
+
return jsonResult(await client.request("POST", "/image/interpret", { formData: form }));
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
return errorResult(err);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import type { OrchestratorClient } from "../orchestrator-client.ts";
|
|
3
|
+
import type { CapabilityGate } from "../capabilities.ts";
|
|
4
|
+
export declare function registerPageTools(server: McpServer, client: OrchestratorClient, gate?: CapabilityGate): void;
|