@getly/mcp 0.1.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/README.md +110 -0
- package/dist/api.d.ts +72 -0
- package/dist/api.js +109 -0
- package/dist/categories.d.ts +19 -0
- package/dist/categories.js +86 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +29 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +4 -0
- package/dist/init.d.ts +9 -0
- package/dist/init.js +133 -0
- package/dist/server.d.ts +10 -0
- package/dist/server.js +33 -0
- package/dist/tools.d.ts +28 -0
- package/dist/tools.js +667 -0
- package/package.json +57 -0
package/dist/tools.js
ADDED
|
@@ -0,0 +1,667 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Getly MCP tool registry — 18 tools.
|
|
3
|
+
*
|
|
4
|
+
* Safety model:
|
|
5
|
+
* - The API key comes ONLY from the GETLY_API_KEY environment variable.
|
|
6
|
+
* Tools never accept keys as arguments and never echo them.
|
|
7
|
+
* - Destructive / publishing / high-discount actions require `confirm: true`.
|
|
8
|
+
* Without it the tool REFUSES and tells the model to ask the human first.
|
|
9
|
+
* - There is intentionally NO bulk-delete tool.
|
|
10
|
+
*
|
|
11
|
+
* Every tool talks to the Getly v1 API (https://www.getly.store/developers).
|
|
12
|
+
* Money is ALWAYS integer cents (priceCents / valueCents / *Cents).
|
|
13
|
+
*/
|
|
14
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
15
|
+
import { basename, extname } from 'node:path';
|
|
16
|
+
import { z } from 'zod';
|
|
17
|
+
import { GetlyError } from '@getly/sdk';
|
|
18
|
+
import { apiRequest, getApiKey, getBaseUrl, getClient, GetlyApiError } from './api.js';
|
|
19
|
+
import { searchCategories } from './categories.js';
|
|
20
|
+
function text(s) {
|
|
21
|
+
return { content: [{ type: 'text', text: s }] };
|
|
22
|
+
}
|
|
23
|
+
function json(data) {
|
|
24
|
+
return { content: [{ type: 'text', text: JSON.stringify(data, null, 2) }] };
|
|
25
|
+
}
|
|
26
|
+
function refusal(s) {
|
|
27
|
+
return { content: [{ type: 'text', text: s }], isError: true };
|
|
28
|
+
}
|
|
29
|
+
export const MISSING_KEY_MESSAGE = [
|
|
30
|
+
'GETLY_API_KEY is not set — this MCP server reads the Getly API key ONLY from that environment variable (never pass keys as tool arguments, never paste them into chat).',
|
|
31
|
+
'',
|
|
32
|
+
'To set it up, tell the user to:',
|
|
33
|
+
'1. Create an API key at https://www.getly.store/dashboard/developer/keys (grant only the scopes this workflow needs).',
|
|
34
|
+
"2. Run `npx @getly/mcp init` to add the key to their MCP client config, or set GETLY_API_KEY in this server's environment, then restart the MCP client.",
|
|
35
|
+
].join('\n');
|
|
36
|
+
function formatApiError(err) {
|
|
37
|
+
// GetlyApiError (this package's thin client) and GetlyError (@getly/sdk)
|
|
38
|
+
// carry the same platform error envelope — format them identically.
|
|
39
|
+
if (err instanceof GetlyApiError || err instanceof GetlyError) {
|
|
40
|
+
const retryAfterSeconds = err instanceof GetlyError ? err.rateLimit.retryAfterSeconds : err.retryAfterSeconds;
|
|
41
|
+
const lines = [`Getly API error \`${err.code}\` (HTTP ${err.status}): ${err.message}`];
|
|
42
|
+
if (err.param)
|
|
43
|
+
lines.push(`Field: ${err.param}`);
|
|
44
|
+
if (err.hint)
|
|
45
|
+
lines.push(`Hint: ${err.hint}`);
|
|
46
|
+
if (err.reasons && err.reasons.length > 0) {
|
|
47
|
+
lines.push('Blockers:');
|
|
48
|
+
for (const r of err.reasons)
|
|
49
|
+
lines.push(`- ${r.code}: ${r.detail}`);
|
|
50
|
+
}
|
|
51
|
+
if (retryAfterSeconds)
|
|
52
|
+
lines.push(`Retry after ${retryAfterSeconds}s.`);
|
|
53
|
+
if (err.docsUrl)
|
|
54
|
+
lines.push(`Docs: ${err.docsUrl}`);
|
|
55
|
+
return { content: [{ type: 'text', text: lines.join('\n') }], isError: true };
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
content: [{ type: 'text', text: `Unexpected error: ${err instanceof Error ? err.message : String(err)}` }],
|
|
59
|
+
isError: true,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
/** Wrap a handler with the missing-key gate + API error formatting. */
|
|
63
|
+
function guarded(requiresAuth, fn) {
|
|
64
|
+
return async (args) => {
|
|
65
|
+
if (requiresAuth && !getApiKey())
|
|
66
|
+
return refusal(MISSING_KEY_MESSAGE);
|
|
67
|
+
try {
|
|
68
|
+
return await fn(args ?? {});
|
|
69
|
+
}
|
|
70
|
+
catch (err) {
|
|
71
|
+
return formatApiError(err);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
function confirmRefusal(action) {
|
|
76
|
+
return refusal(`REFUSED: ${action} requires \`confirm: true\`. Do NOT set confirm yourself — ` +
|
|
77
|
+
'first ask the human user to explicitly approve this exact action, and only after ' +
|
|
78
|
+
'they approve, call the tool again with confirm: true.');
|
|
79
|
+
}
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Shared schema fragments
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const cursorParams = {
|
|
84
|
+
limit: z.number().int().min(1).max(100).optional().describe('Page size (default 20, max 100)'),
|
|
85
|
+
cursor: z.string().optional().describe('Opaque nextCursor from the previous page'),
|
|
86
|
+
};
|
|
87
|
+
const productIdParam = z.string().uuid().describe('Product id (uuid)');
|
|
88
|
+
function projectProduct(p) {
|
|
89
|
+
return {
|
|
90
|
+
id: p.id,
|
|
91
|
+
name: p.name,
|
|
92
|
+
slug: p.slug,
|
|
93
|
+
status: p.status,
|
|
94
|
+
priceCents: p.priceCents,
|
|
95
|
+
compareAtPriceCents: p.compareAtPriceCents,
|
|
96
|
+
licenseKeysEnabled: p.licenseKeysEnabled,
|
|
97
|
+
category: p.category ? { name: p.category.name, slug: p.category.slug } : null,
|
|
98
|
+
url: p.urls?.product,
|
|
99
|
+
createdAt: p.createdAt,
|
|
100
|
+
...(p.moderationStatus ? { moderationStatus: p.moderationStatus, note: p.note } : {}),
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
const IMAGE_TYPES = {
|
|
104
|
+
'.png': 'image/png',
|
|
105
|
+
'.jpg': 'image/jpeg',
|
|
106
|
+
'.jpeg': 'image/jpeg',
|
|
107
|
+
'.webp': 'image/webp',
|
|
108
|
+
'.gif': 'image/gif',
|
|
109
|
+
'.avif': 'image/avif',
|
|
110
|
+
};
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// Tools
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
export const TOOLS = [
|
|
115
|
+
// ------------------------------------------------------------- products --
|
|
116
|
+
{
|
|
117
|
+
name: 'list_products',
|
|
118
|
+
description: "List products in the user's Getly store (cursor-paginated). Filter by status (active/draft/pending_review/archived), category id, or name search. Read-only.",
|
|
119
|
+
annotations: { title: 'List products', readOnlyHint: true },
|
|
120
|
+
requiresAuth: true,
|
|
121
|
+
inputSchema: {
|
|
122
|
+
status: z.enum(['active', 'draft', 'pending_review', 'archived']).optional()
|
|
123
|
+
.describe('Filter by status (default: active)'),
|
|
124
|
+
search: z.string().optional().describe('Case-insensitive name search'),
|
|
125
|
+
category: z.string().optional().describe('Category id filter'),
|
|
126
|
+
...cursorParams,
|
|
127
|
+
},
|
|
128
|
+
handler: guarded(true, async (args) => {
|
|
129
|
+
const env = await apiRequest('api/v1/products', {
|
|
130
|
+
query: {
|
|
131
|
+
status: args.status,
|
|
132
|
+
search: args.search,
|
|
133
|
+
category: args.category,
|
|
134
|
+
limit: args.limit,
|
|
135
|
+
cursor: args.cursor,
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
return json({
|
|
139
|
+
items: env.data.items.map(projectProduct),
|
|
140
|
+
nextCursor: env.data.nextCursor,
|
|
141
|
+
});
|
|
142
|
+
}),
|
|
143
|
+
},
|
|
144
|
+
{
|
|
145
|
+
name: 'get_product',
|
|
146
|
+
description: 'Get full details of one product by id: description, priceCents, images, attached files, category, recent reviews, public URLs. Read-only.',
|
|
147
|
+
annotations: { title: 'Get product', readOnlyHint: true },
|
|
148
|
+
requiresAuth: true,
|
|
149
|
+
inputSchema: { productId: productIdParam },
|
|
150
|
+
handler: guarded(true, async (args) => {
|
|
151
|
+
const env = await apiRequest(`api/v1/products/${args.productId}`);
|
|
152
|
+
const data = { ...env.data };
|
|
153
|
+
if (Array.isArray(data.reviews) && data.reviews.length > 5) {
|
|
154
|
+
data.reviews = data.reviews.slice(0, 5);
|
|
155
|
+
data.reviewsTruncated = true;
|
|
156
|
+
}
|
|
157
|
+
return json(data);
|
|
158
|
+
}),
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: 'create_product',
|
|
162
|
+
description: 'Create a product in the Getly store (side effect: creates a DRAFT product; money is integer cents). A product cannot go live without a downloadable file — attach one with upload_product_file, then use publish_product. Daily cap: 20 products per API key.',
|
|
163
|
+
annotations: { title: 'Create product' },
|
|
164
|
+
requiresAuth: true,
|
|
165
|
+
inputSchema: {
|
|
166
|
+
name: z.string().min(1).max(200).describe('Product name'),
|
|
167
|
+
priceCents: z.number().int().min(0)
|
|
168
|
+
.describe('Price in integer cents (e.g. 1999 = $19.99; 0 = free)'),
|
|
169
|
+
description: z.string().optional().describe('Long description (plain text or simple HTML)'),
|
|
170
|
+
shortDescription: z.string().max(500).optional().describe('One-line summary'),
|
|
171
|
+
compareAtPriceCents: z.number().int().min(0).optional()
|
|
172
|
+
.describe('Strike-through "was" price in cents (must be above priceCents to make sense)'),
|
|
173
|
+
categoryId: z.string().optional()
|
|
174
|
+
.describe('Category id — find one with search_categories'),
|
|
175
|
+
tags: z.array(z.string()).max(20).optional().describe('Search tags'),
|
|
176
|
+
images: z.array(z.object({
|
|
177
|
+
url: z.string().url().describe('Image URL (upload local files first via upload_image)'),
|
|
178
|
+
altText: z.string().max(255).optional(),
|
|
179
|
+
})).max(20).optional().describe('Product images (first = cover)'),
|
|
180
|
+
licenseKeysEnabled: z.boolean().optional()
|
|
181
|
+
.describe('Issue a license key with every sale'),
|
|
182
|
+
licenseActivationLimit: z.number().int().min(1).max(100).optional()
|
|
183
|
+
.describe('Activation seats per license key (default 3)'),
|
|
184
|
+
},
|
|
185
|
+
handler: guarded(true, async (args) => {
|
|
186
|
+
const env = await apiRequest('api/v1/products', {
|
|
187
|
+
method: 'POST',
|
|
188
|
+
idempotent: true,
|
|
189
|
+
body: {
|
|
190
|
+
name: args.name,
|
|
191
|
+
priceCents: args.priceCents,
|
|
192
|
+
description: args.description,
|
|
193
|
+
shortDescription: args.shortDescription,
|
|
194
|
+
compareAtPriceCents: args.compareAtPriceCents,
|
|
195
|
+
categoryId: args.categoryId,
|
|
196
|
+
tags: args.tags,
|
|
197
|
+
images: args.images,
|
|
198
|
+
licenseKeysEnabled: args.licenseKeysEnabled,
|
|
199
|
+
licenseActivationLimit: args.licenseActivationLimit,
|
|
200
|
+
status: 'draft',
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
return json({
|
|
204
|
+
created: projectProduct(env.data),
|
|
205
|
+
nextSteps: 'The product is a draft. Attach a downloadable file with upload_product_file, then publish with publish_product (needs human confirmation).',
|
|
206
|
+
});
|
|
207
|
+
}),
|
|
208
|
+
},
|
|
209
|
+
{
|
|
210
|
+
name: 'update_product',
|
|
211
|
+
description: 'Update fields of an existing product (side effect: modifies the live listing). Cannot publish or archive from here — use publish_product / archive_product, which require human confirmation. Money is integer cents.',
|
|
212
|
+
annotations: { title: 'Update product', idempotentHint: true },
|
|
213
|
+
requiresAuth: true,
|
|
214
|
+
inputSchema: {
|
|
215
|
+
productId: productIdParam,
|
|
216
|
+
name: z.string().min(1).max(200).optional(),
|
|
217
|
+
priceCents: z.number().int().min(0).optional().describe('New price in integer cents'),
|
|
218
|
+
description: z.string().optional(),
|
|
219
|
+
shortDescription: z.string().max(500).optional(),
|
|
220
|
+
compareAtPriceCents: z.number().int().min(0).nullable().optional()
|
|
221
|
+
.describe('Strike-through price in cents; null clears it'),
|
|
222
|
+
categoryId: z.string().optional(),
|
|
223
|
+
tags: z.array(z.string()).max(20).optional(),
|
|
224
|
+
images: z.array(z.object({
|
|
225
|
+
url: z.string().url(),
|
|
226
|
+
altText: z.string().max(255).optional(),
|
|
227
|
+
})).max(20).optional().describe('REPLACES the whole image set when provided'),
|
|
228
|
+
status: z.enum(['draft']).optional()
|
|
229
|
+
.describe("Only 'draft' (unpublish) is allowed here. Publishing requires publish_product; archiving requires archive_product — both need human confirmation."),
|
|
230
|
+
licenseKeysEnabled: z.boolean().optional(),
|
|
231
|
+
licenseActivationLimit: z.number().int().min(1).max(100).optional(),
|
|
232
|
+
},
|
|
233
|
+
handler: guarded(true, async (args) => {
|
|
234
|
+
const { productId, ...rest } = args;
|
|
235
|
+
const body = {};
|
|
236
|
+
for (const [k, v] of Object.entries(rest))
|
|
237
|
+
if (v !== undefined)
|
|
238
|
+
body[k] = v;
|
|
239
|
+
const env = await apiRequest(`api/v1/products/${productId}`, {
|
|
240
|
+
method: 'PATCH',
|
|
241
|
+
body,
|
|
242
|
+
});
|
|
243
|
+
return json({ updated: projectProduct(env.data) });
|
|
244
|
+
}),
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
name: 'publish_product',
|
|
248
|
+
description: 'Publish a draft product so it becomes publicly purchasable (side effect: goes live and can take real money). REQUIRES confirm: true — ask the human user for explicit approval first. New stores without a completed sale go to moderation review instead of straight live. Returns machine-readable blockers if the product is not ready (e.g. missing downloadable file).',
|
|
249
|
+
annotations: { title: 'Publish product' },
|
|
250
|
+
requiresAuth: true,
|
|
251
|
+
inputSchema: {
|
|
252
|
+
productId: productIdParam,
|
|
253
|
+
confirm: z.boolean().optional()
|
|
254
|
+
.describe('Must be true. Only set it after the human user explicitly approved publishing THIS product.'),
|
|
255
|
+
},
|
|
256
|
+
handler: guarded(true, async (args) => {
|
|
257
|
+
if (args.confirm !== true) {
|
|
258
|
+
return confirmRefusal(`publish_product (product ${String(args.productId)} would go live and become purchasable)`);
|
|
259
|
+
}
|
|
260
|
+
const env = await apiRequest(`api/v1/products/${args.productId}/publish`, { method: 'POST', idempotent: true });
|
|
261
|
+
const p = env.data;
|
|
262
|
+
const summary = p.status === 'active'
|
|
263
|
+
? `Published — the product is live at ${p.urls?.product ?? 'its product page'}.`
|
|
264
|
+
: `Submitted — the product is in moderation review (status: ${p.status}). ${p.note ?? ''}`.trim();
|
|
265
|
+
return json({ result: summary, product: projectProduct(p) });
|
|
266
|
+
}),
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
name: 'archive_product',
|
|
270
|
+
description: 'Archive (soft-delete) a product: it disappears from the store and stops selling; existing buyers keep their downloads. DESTRUCTIVE — REQUIRES confirm: true; ask the human user for explicit approval first. Reversible only by re-publishing later.',
|
|
271
|
+
annotations: { title: 'Archive product', destructiveHint: true },
|
|
272
|
+
requiresAuth: true,
|
|
273
|
+
inputSchema: {
|
|
274
|
+
productId: productIdParam,
|
|
275
|
+
confirm: z.boolean().optional()
|
|
276
|
+
.describe('Must be true. Only set it after the human user explicitly approved archiving THIS product.'),
|
|
277
|
+
},
|
|
278
|
+
handler: guarded(true, async (args) => {
|
|
279
|
+
if (args.confirm !== true) {
|
|
280
|
+
return confirmRefusal(`archive_product (product ${String(args.productId)} would be removed from sale)`);
|
|
281
|
+
}
|
|
282
|
+
const env = await apiRequest(`api/v1/products/${args.productId}`, { method: 'DELETE' });
|
|
283
|
+
return json({ result: 'Product archived (removed from sale).', ...env.data });
|
|
284
|
+
}),
|
|
285
|
+
},
|
|
286
|
+
{
|
|
287
|
+
name: 'upload_product_file',
|
|
288
|
+
description: 'Upload a local file as the downloadable a buyer receives (side effect: attaches the file to the product). SLOW for large files (up to 2GB) — the bytes are read from disk and uploaded to storage; do not retry while a call is still running. Flow: presigned URL → upload → attach.',
|
|
289
|
+
annotations: { title: 'Upload product file (slow)' },
|
|
290
|
+
requiresAuth: true,
|
|
291
|
+
inputSchema: {
|
|
292
|
+
productId: productIdParam,
|
|
293
|
+
filePath: z.string().describe('Absolute path of the local file to upload'),
|
|
294
|
+
fileType: z.string().optional()
|
|
295
|
+
.describe('MIME type (default application/octet-stream)'),
|
|
296
|
+
versionNotes: z.string().max(2000).optional().describe('Changelog note for this file version'),
|
|
297
|
+
},
|
|
298
|
+
handler: guarded(true, async (args) => {
|
|
299
|
+
const filePath = String(args.filePath);
|
|
300
|
+
const info = await stat(filePath).catch(() => null);
|
|
301
|
+
if (!info?.isFile())
|
|
302
|
+
return refusal(`File not found (or not a regular file): ${filePath}`);
|
|
303
|
+
const fileName = basename(filePath);
|
|
304
|
+
const fileType = args.fileType || 'application/octet-stream';
|
|
305
|
+
// @getly/sdk one-call flow: presign → PUT the bytes → attach.
|
|
306
|
+
const bytes = await readFile(filePath);
|
|
307
|
+
const file = await getClient().products.uploadFile(String(args.productId), {
|
|
308
|
+
fileName,
|
|
309
|
+
data: bytes,
|
|
310
|
+
fileType,
|
|
311
|
+
versionNotes: args.versionNotes,
|
|
312
|
+
});
|
|
313
|
+
return json({
|
|
314
|
+
result: `Attached "${fileName}" (${info.size} bytes) to the product.`,
|
|
315
|
+
file,
|
|
316
|
+
});
|
|
317
|
+
}),
|
|
318
|
+
},
|
|
319
|
+
{
|
|
320
|
+
name: 'upload_image',
|
|
321
|
+
description: 'Upload a local image (png/jpg/webp/gif/avif, max 10MB) to Getly storage and return its public URL — use that URL in create_product/update_product images[] or as a blog post coverImageUrl. Side effect: stores the image (auto-deleted after 24h if never attached to anything).',
|
|
322
|
+
annotations: { title: 'Upload image' },
|
|
323
|
+
requiresAuth: true,
|
|
324
|
+
inputSchema: {
|
|
325
|
+
filePath: z.string().describe('Absolute path of the local image file'),
|
|
326
|
+
},
|
|
327
|
+
handler: guarded(true, async (args) => {
|
|
328
|
+
const filePath = String(args.filePath);
|
|
329
|
+
const contentType = IMAGE_TYPES[extname(filePath).toLowerCase()];
|
|
330
|
+
if (!contentType) {
|
|
331
|
+
return refusal('Unsupported image type. Allowed: .png, .jpg/.jpeg, .webp, .gif, .avif');
|
|
332
|
+
}
|
|
333
|
+
const info = await stat(filePath).catch(() => null);
|
|
334
|
+
if (!info?.isFile())
|
|
335
|
+
return refusal(`File not found (or not a regular file): ${filePath}`);
|
|
336
|
+
if (info.size > 10 * 1024 * 1024)
|
|
337
|
+
return refusal('Image too large — max 10MB.');
|
|
338
|
+
// @getly/sdk one-call flow: presignImage → PUT the bytes.
|
|
339
|
+
const bytes = await readFile(filePath);
|
|
340
|
+
const { publicUrl } = await getClient().uploads.uploadImage({
|
|
341
|
+
data: bytes,
|
|
342
|
+
contentType,
|
|
343
|
+
fileName: basename(filePath),
|
|
344
|
+
});
|
|
345
|
+
return json({
|
|
346
|
+
url: publicUrl,
|
|
347
|
+
note: 'Use this URL as a product image or a blog post cover within 24 hours, or it will be garbage-collected.',
|
|
348
|
+
});
|
|
349
|
+
}),
|
|
350
|
+
},
|
|
351
|
+
// ----------------------------------------------------------------- blog --
|
|
352
|
+
{
|
|
353
|
+
name: 'create_blog_post',
|
|
354
|
+
description: "Create a blog post on the user's Getly store (side effect: creates a draft, or publishes immediately when status=published). Markdown is the source of truth; embed a product buy-card with the [product:slug] shortcode. Daily cap: 5 posts per API key. New stores' posts are noindex until the store is trusted.",
|
|
355
|
+
annotations: { title: 'Create blog post' },
|
|
356
|
+
requiresAuth: true,
|
|
357
|
+
inputSchema: {
|
|
358
|
+
title: z.string().min(1).max(500).describe('Post title'),
|
|
359
|
+
contentMarkdown: z.string().min(1)
|
|
360
|
+
.describe('Post body in Markdown (max 100KB). Use [product:slug] to embed a product card with a buy button.'),
|
|
361
|
+
excerpt: z.string().max(500).optional().describe('Short teaser shown in lists'),
|
|
362
|
+
coverImageUrl: z.string().url().optional()
|
|
363
|
+
.describe('Cover image URL (upload local images first via upload_image)'),
|
|
364
|
+
slug: z.string().optional().describe('URL slug override (auto-generated from the title when omitted)'),
|
|
365
|
+
status: z.enum(['draft', 'published']).optional()
|
|
366
|
+
.describe('draft (default) or published (goes live immediately)'),
|
|
367
|
+
},
|
|
368
|
+
handler: guarded(true, async (args) => {
|
|
369
|
+
const env = await apiRequest('api/v1/posts', {
|
|
370
|
+
method: 'POST',
|
|
371
|
+
idempotent: true,
|
|
372
|
+
body: {
|
|
373
|
+
title: args.title,
|
|
374
|
+
contentMarkdown: args.contentMarkdown,
|
|
375
|
+
excerpt: args.excerpt,
|
|
376
|
+
coverImageUrl: args.coverImageUrl,
|
|
377
|
+
slug: args.slug,
|
|
378
|
+
status: args.status,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
const data = { ...env.data };
|
|
382
|
+
delete data.contentHtml; // token-heavy derived field
|
|
383
|
+
return json({ post: data, ...(env.warnings ? { warnings: env.warnings } : {}) });
|
|
384
|
+
}),
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
name: 'list_blog_posts',
|
|
388
|
+
description: "List the store's blog posts (cursor-paginated, filter by draft/published). Read-only.",
|
|
389
|
+
annotations: { title: 'List blog posts', readOnlyHint: true },
|
|
390
|
+
requiresAuth: true,
|
|
391
|
+
inputSchema: {
|
|
392
|
+
status: z.enum(['draft', 'published']).optional().describe('Status filter'),
|
|
393
|
+
...cursorParams,
|
|
394
|
+
},
|
|
395
|
+
handler: guarded(true, async (args) => {
|
|
396
|
+
const env = await apiRequest('api/v1/posts', {
|
|
397
|
+
query: {
|
|
398
|
+
status: args.status,
|
|
399
|
+
limit: args.limit,
|
|
400
|
+
cursor: args.cursor,
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const items = env.data.items.map((p) => ({
|
|
404
|
+
id: p.id,
|
|
405
|
+
title: p.title,
|
|
406
|
+
slug: p.slug,
|
|
407
|
+
status: p.status,
|
|
408
|
+
excerpt: p.excerpt,
|
|
409
|
+
publishedAt: p.publishedAt,
|
|
410
|
+
createdAt: p.createdAt,
|
|
411
|
+
}));
|
|
412
|
+
return json({ items, nextCursor: env.data.nextCursor });
|
|
413
|
+
}),
|
|
414
|
+
},
|
|
415
|
+
// -------------------------------------------------------------- coupons --
|
|
416
|
+
{
|
|
417
|
+
name: 'create_coupon',
|
|
418
|
+
description: 'Create a discount coupon (side effect: buyers can immediately redeem it and pay less). type=percentage → value is 1-100; type=fixed → value is integer CENTS off. Discounts of 50%+ REQUIRE confirm: true — ask the human user first. Daily cap: 30 coupons per API key.',
|
|
419
|
+
annotations: { title: 'Create coupon' },
|
|
420
|
+
requiresAuth: true,
|
|
421
|
+
inputSchema: {
|
|
422
|
+
code: z.string().min(3).max(50)
|
|
423
|
+
.describe('Coupon code (3-50 chars, letters/digits/dashes; normalized to UPPERCASE)'),
|
|
424
|
+
type: z.enum(['percentage', 'fixed']).describe('percentage of the order, or fixed cents off'),
|
|
425
|
+
value: z.number().int().min(1)
|
|
426
|
+
.describe('percentage: 1-100; fixed: integer cents off (e.g. 500 = $5.00)'),
|
|
427
|
+
minOrderAmountCents: z.number().int().min(0).optional()
|
|
428
|
+
.describe('Minimum order total in cents to qualify'),
|
|
429
|
+
maxUses: z.number().int().min(1).optional()
|
|
430
|
+
.describe('Total redemption limit (unlimited when omitted)'),
|
|
431
|
+
expiresAt: z.string().optional()
|
|
432
|
+
.describe('Expiry date (ISO 8601; date-only means end of that day)'),
|
|
433
|
+
confirm: z.boolean().optional()
|
|
434
|
+
.describe('Required (true) when the discount is a percentage of 50 or more. Only set it after the human user explicitly approved this discount.'),
|
|
435
|
+
},
|
|
436
|
+
handler: guarded(true, async (args) => {
|
|
437
|
+
const type = args.type;
|
|
438
|
+
const value = args.value;
|
|
439
|
+
if (type === 'percentage' && value >= 50 && args.confirm !== true) {
|
|
440
|
+
return confirmRefusal(`create_coupon with a ${value}% discount (this cuts real revenue on every redemption)`);
|
|
441
|
+
}
|
|
442
|
+
const env = await apiRequest('api/v1/coupons', {
|
|
443
|
+
method: 'POST',
|
|
444
|
+
idempotent: true,
|
|
445
|
+
body: {
|
|
446
|
+
code: args.code,
|
|
447
|
+
type,
|
|
448
|
+
value,
|
|
449
|
+
minOrderAmountCents: args.minOrderAmountCents,
|
|
450
|
+
maxUses: args.maxUses,
|
|
451
|
+
expiresAt: args.expiresAt,
|
|
452
|
+
// The API separately requires this acknowledgement at >=90%; the
|
|
453
|
+
// human already confirmed via the 50%+ confirm gate above.
|
|
454
|
+
...(type === 'percentage' && value >= 90 ? { acknowledgeHighDiscount: true } : {}),
|
|
455
|
+
},
|
|
456
|
+
});
|
|
457
|
+
return json({ coupon: env.data });
|
|
458
|
+
}),
|
|
459
|
+
},
|
|
460
|
+
{
|
|
461
|
+
name: 'list_coupons',
|
|
462
|
+
description: "List the store's coupons (cursor-paginated, filter by active). Read-only.",
|
|
463
|
+
annotations: { title: 'List coupons', readOnlyHint: true },
|
|
464
|
+
requiresAuth: true,
|
|
465
|
+
inputSchema: {
|
|
466
|
+
active: z.boolean().optional().describe('Filter: only active (true) / only inactive (false)'),
|
|
467
|
+
...cursorParams,
|
|
468
|
+
},
|
|
469
|
+
handler: guarded(true, async (args) => {
|
|
470
|
+
const env = await apiRequest('api/v1/coupons', {
|
|
471
|
+
query: {
|
|
472
|
+
active: args.active,
|
|
473
|
+
limit: args.limit,
|
|
474
|
+
cursor: args.cursor,
|
|
475
|
+
},
|
|
476
|
+
});
|
|
477
|
+
const items = env.data.items.map((c) => ({
|
|
478
|
+
id: c.id,
|
|
479
|
+
code: c.code,
|
|
480
|
+
type: c.type,
|
|
481
|
+
value: c.value,
|
|
482
|
+
...(c.type === 'fixed' ? { valueCents: c.valueCents ?? c.value } : {}),
|
|
483
|
+
minOrderAmountCents: c.minOrderAmountCents,
|
|
484
|
+
maxUses: c.maxUses,
|
|
485
|
+
usedCount: c.usedCount,
|
|
486
|
+
isActive: c.isActive,
|
|
487
|
+
expiresAt: c.expiresAt,
|
|
488
|
+
}));
|
|
489
|
+
return json({ items, nextCursor: env.data.nextCursor });
|
|
490
|
+
}),
|
|
491
|
+
},
|
|
492
|
+
// ------------------------------------------------------- checkout links --
|
|
493
|
+
{
|
|
494
|
+
name: 'create_checkout_link',
|
|
495
|
+
description: 'Create an instant payment link for one product (side effect: anyone with the URL can buy; an optional coupon is auto-applied at checkout). Idempotent per (product, coupon, reference): re-creating with the same trio returns the existing open link. Default expiry 7 days (max 30). Poll completion with get_checkout_link_status.',
|
|
496
|
+
annotations: { title: 'Create checkout link', idempotentHint: true },
|
|
497
|
+
requiresAuth: true,
|
|
498
|
+
inputSchema: {
|
|
499
|
+
productId: productIdParam,
|
|
500
|
+
couponCode: z.string().max(50).optional()
|
|
501
|
+
.describe('Coupon code to auto-apply (validated now and again at redemption)'),
|
|
502
|
+
affiliateCode: z.string().max(50).optional()
|
|
503
|
+
.describe('Attribute the sale to this affiliate (self-referral is rejected)'),
|
|
504
|
+
reference: z.string().max(200).optional()
|
|
505
|
+
.describe('Your correlation id — echoed in webhooks and status polling'),
|
|
506
|
+
metadata: z.record(z.string(), z.string()).optional()
|
|
507
|
+
.describe('Up to 20 string key/values (≤2KB) echoed in sale.completed'),
|
|
508
|
+
successUrl: z.string().url().optional().describe('https URL the buyer lands on after paying'),
|
|
509
|
+
cancelUrl: z.string().url().optional().describe('https URL when the buyer cancels'),
|
|
510
|
+
expiresInHours: z.number().int().min(1).max(720).optional()
|
|
511
|
+
.describe('Link lifetime in hours (default 168 = 7 days, max 720)'),
|
|
512
|
+
},
|
|
513
|
+
handler: guarded(true, async (args) => {
|
|
514
|
+
const env = await apiRequest('api/v1/checkout-links', {
|
|
515
|
+
method: 'POST',
|
|
516
|
+
idempotent: true,
|
|
517
|
+
body: {
|
|
518
|
+
productId: args.productId,
|
|
519
|
+
couponCode: args.couponCode,
|
|
520
|
+
affiliateCode: args.affiliateCode,
|
|
521
|
+
reference: args.reference,
|
|
522
|
+
metadata: args.metadata,
|
|
523
|
+
successUrl: args.successUrl,
|
|
524
|
+
cancelUrl: args.cancelUrl,
|
|
525
|
+
expiresInHours: args.expiresInHours,
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
const d = env.data;
|
|
529
|
+
return json({
|
|
530
|
+
url: d.url,
|
|
531
|
+
id: d.id,
|
|
532
|
+
status: d.status,
|
|
533
|
+
priceCents: d.priceCents,
|
|
534
|
+
discountedPriceCents: d.discountedPriceCents,
|
|
535
|
+
couponApplied: d.couponApplied,
|
|
536
|
+
reference: d.reference,
|
|
537
|
+
expiresAt: d.expiresAt,
|
|
538
|
+
});
|
|
539
|
+
}),
|
|
540
|
+
},
|
|
541
|
+
{
|
|
542
|
+
name: 'get_checkout_link_status',
|
|
543
|
+
description: 'Check whether a checkout link was paid: returns open | completed | expired (+ orderId when completed). Use this to poll for payment when you cannot receive webhooks. Read-only.',
|
|
544
|
+
annotations: { title: 'Checkout link status', readOnlyHint: true },
|
|
545
|
+
requiresAuth: true,
|
|
546
|
+
inputSchema: {
|
|
547
|
+
linkId: z.string().uuid().describe('Checkout link id (from create_checkout_link)'),
|
|
548
|
+
},
|
|
549
|
+
handler: guarded(true, async (args) => {
|
|
550
|
+
const env = await apiRequest(`api/v1/checkout-links/${args.linkId}`);
|
|
551
|
+
return json(env.data);
|
|
552
|
+
}),
|
|
553
|
+
},
|
|
554
|
+
// ------------------------------------------------------------- licenses --
|
|
555
|
+
{
|
|
556
|
+
name: 'list_licenses',
|
|
557
|
+
description: "List license keys issued for the store's products (cursor-paginated, filter by productId). Shows activation counts and devices. Read-only.",
|
|
558
|
+
annotations: { title: 'List license keys', readOnlyHint: true },
|
|
559
|
+
requiresAuth: true,
|
|
560
|
+
inputSchema: {
|
|
561
|
+
productId: z.string().uuid().optional().describe('Only keys of this product'),
|
|
562
|
+
...cursorParams,
|
|
563
|
+
},
|
|
564
|
+
handler: guarded(true, async (args) => {
|
|
565
|
+
const env = await apiRequest('api/v1/licenses', {
|
|
566
|
+
query: {
|
|
567
|
+
productId: args.productId,
|
|
568
|
+
limit: args.limit,
|
|
569
|
+
cursor: args.cursor,
|
|
570
|
+
},
|
|
571
|
+
});
|
|
572
|
+
return json(env.data);
|
|
573
|
+
}),
|
|
574
|
+
},
|
|
575
|
+
// ---------------------------------------------------------------- stats --
|
|
576
|
+
{
|
|
577
|
+
name: 'get_sales_stats',
|
|
578
|
+
description: 'Sales overview for the store: total/monthly sales and revenue (integer cents), average order value, per-month breakdown, product count, downloads, plus the most recent orders. Read-only. Revenue figures are the SELLER share (after the platform fee).',
|
|
579
|
+
annotations: { title: 'Sales stats', readOnlyHint: true },
|
|
580
|
+
requiresAuth: true,
|
|
581
|
+
inputSchema: {},
|
|
582
|
+
handler: guarded(true, async () => {
|
|
583
|
+
const analytics = await apiRequest('api/v1/analytics');
|
|
584
|
+
const a = analytics.data;
|
|
585
|
+
// Recent orders are a nice-to-have — degrade gracefully when the key
|
|
586
|
+
// lacks read:orders.
|
|
587
|
+
let recentOrders = 'unavailable (key lacks the read:orders scope)';
|
|
588
|
+
try {
|
|
589
|
+
const orders = await apiRequest('api/v1/orders', {
|
|
590
|
+
query: { limit: 5 },
|
|
591
|
+
});
|
|
592
|
+
recentOrders = (orders.data ?? []).map((i) => {
|
|
593
|
+
const product = i.product;
|
|
594
|
+
const order = i.order;
|
|
595
|
+
return {
|
|
596
|
+
orderItemId: i.id,
|
|
597
|
+
product: product?.name,
|
|
598
|
+
sellerAmountCents: i.sellerAmount,
|
|
599
|
+
orderStatus: order?.status,
|
|
600
|
+
createdAt: i.createdAt,
|
|
601
|
+
};
|
|
602
|
+
});
|
|
603
|
+
}
|
|
604
|
+
catch {
|
|
605
|
+
/* keep the fallback note */
|
|
606
|
+
}
|
|
607
|
+
return json({
|
|
608
|
+
totalSales: a.totalSales,
|
|
609
|
+
totalRevenueCents: a.totalRevenue,
|
|
610
|
+
monthlySales: a.monthlySales,
|
|
611
|
+
monthlyRevenueCents: a.monthlyRevenue,
|
|
612
|
+
averageOrderValueCents: a.averageOrderValue,
|
|
613
|
+
productCount: a.productCount,
|
|
614
|
+
totalDownloads: a.totalDownloads,
|
|
615
|
+
salesByMonth: (a.salesByMonth ?? []).map((m) => ({
|
|
616
|
+
month: m.month,
|
|
617
|
+
sales: Number(m.sales),
|
|
618
|
+
revenueCents: Number(m.revenue),
|
|
619
|
+
})),
|
|
620
|
+
recentOrders,
|
|
621
|
+
});
|
|
622
|
+
}),
|
|
623
|
+
},
|
|
624
|
+
// ----------------------------------------------------------- categories --
|
|
625
|
+
{
|
|
626
|
+
name: 'search_categories',
|
|
627
|
+
description: 'Find Getly category ids by fuzzy name search (e.g. "icons", "3d models", "fonts") over the public 700+ category tree. Use the returned id as categoryId in create_product/update_product. Read-only, no API key needed, cached 1h.',
|
|
628
|
+
annotations: { title: 'Search categories', readOnlyHint: true },
|
|
629
|
+
requiresAuth: false,
|
|
630
|
+
inputSchema: {
|
|
631
|
+
query: z.string().min(1).describe('Category name or keywords'),
|
|
632
|
+
limit: z.number().int().min(1).max(50).optional().describe('Max results (default 10)'),
|
|
633
|
+
},
|
|
634
|
+
handler: guarded(false, async (args) => {
|
|
635
|
+
const matches = await searchCategories(String(args.query), args.limit ?? 10);
|
|
636
|
+
if (matches.length === 0) {
|
|
637
|
+
return text(`No categories matched "${String(args.query)}". Try a broader keyword (e.g. "graphics", "audio", "templates").`);
|
|
638
|
+
}
|
|
639
|
+
return json(matches.map(({ id, name, slug, path }) => ({ id, name, slug, path })));
|
|
640
|
+
}),
|
|
641
|
+
},
|
|
642
|
+
// ------------------------------------------------------------------ store --
|
|
643
|
+
{
|
|
644
|
+
name: 'get_store',
|
|
645
|
+
description: "Get the user's store profile: name, slug, description, verification, public URL. Read-only.",
|
|
646
|
+
annotations: { title: 'Get store', readOnlyHint: true },
|
|
647
|
+
requiresAuth: true,
|
|
648
|
+
inputSchema: {},
|
|
649
|
+
handler: guarded(true, async () => {
|
|
650
|
+
const env = await apiRequest('api/v1/store');
|
|
651
|
+
const s = env.data;
|
|
652
|
+
return json({
|
|
653
|
+
id: s.id,
|
|
654
|
+
name: s.name,
|
|
655
|
+
slug: s.slug,
|
|
656
|
+
description: s.description,
|
|
657
|
+
website: s.website,
|
|
658
|
+
isVerified: s.isVerified,
|
|
659
|
+
commissionPercent: s.commissionPercent,
|
|
660
|
+
createdAt: s.createdAt,
|
|
661
|
+
url: `${getBaseUrl()}/store/${String(s.slug)}`,
|
|
662
|
+
});
|
|
663
|
+
}),
|
|
664
|
+
},
|
|
665
|
+
];
|
|
666
|
+
// Sanity: the registry is the single source of truth for tool names.
|
|
667
|
+
export const TOOL_NAMES = TOOLS.map((t) => t.name);
|