@commerceforai/mcp 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,32 @@
1
- # Temporary Holding Version
1
+ # @commerceforai/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Let an AI agent run your [CommerceForAI](https://commerceforai.com) store: products, inventory, orders, fulfillment, returns, discounts, reports, image hosting, Printify and test/live sync.
4
+
5
+ ## No install: hosted server
6
+
7
+ In Claude.ai, ChatGPT or any MCP client that supports remote servers, add `https://api.commerceforai.com/mcp`. You sign in, pick a store, and choose read-only or full access.
8
+
9
+ ## Local (stdio), with an API key
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "commerce": {
15
+ "command": "npx",
16
+ "args": ["-y", "@commerceforai/mcp"],
17
+ "env": { "COMMERCEFORAI_API_KEY": "cfa_...", "COMMERCEFORAI_MAX_REFUND": "5000" }
18
+ }
19
+ }
20
+ }
21
+ ```
22
+
23
+ ## Guardrails
24
+
25
+ | Variable | Effect |
26
+ |---|---|
27
+ | `COMMERCEFORAI_READ_ONLY=true` | Only read tools are offered |
28
+ | `COMMERCEFORAI_MAX_REFUND` | Largest refund in cents (default 5000) |
29
+ | `COMMERCEFORAI_MAX_LABEL_COST` | Largest shipping label in cents (default 5000) |
30
+ | `COMMERCEFORAI_TOOLS` | Comma-separated allowlist of tools |
31
+
32
+ [Docs](https://commerceforai.com/docs/guides/agents) · MIT licensed.
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CommerceForAI MCP server — lets an AI agent run a store, inside limits you set.
4
+ *
5
+ * Env:
6
+ * COMMERCEFORAI_API_KEY required
7
+ * COMMERCEFORAI_BASE_URL default https://api.commerceforai.com
8
+ * COMMERCEFORAI_MAX_REFUND max refund per call, in cents (default 5000 = $50; 0 disables refunds)
9
+ * COMMERCEFORAI_MAX_LABEL_COST max shipping label cost, in cents (default 5000 = $50)
10
+ * COMMERCEFORAI_READ_ONLY "true" exposes only read tools
11
+ * COMMERCEFORAI_TOOLS optional comma-separated allowlist of tool names
12
+ */
13
+ import { Commerce } from "commerceforai";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ export interface GuardrailConfig {
16
+ maxRefund: number;
17
+ maxLabelCost?: number;
18
+ readOnly: boolean;
19
+ allow: Set<string> | null;
20
+ }
21
+ export declare function configFromEnv(env?: NodeJS.ProcessEnv): GuardrailConfig;
22
+ export declare function buildServer(commerce: Commerce, cfg: GuardrailConfig): McpServer;
package/dist/index.js ADDED
@@ -0,0 +1,400 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * CommerceForAI MCP server — lets an AI agent run a store, inside limits you set.
4
+ *
5
+ * Env:
6
+ * COMMERCEFORAI_API_KEY required
7
+ * COMMERCEFORAI_BASE_URL default https://api.commerceforai.com
8
+ * COMMERCEFORAI_MAX_REFUND max refund per call, in cents (default 5000 = $50; 0 disables refunds)
9
+ * COMMERCEFORAI_MAX_LABEL_COST max shipping label cost, in cents (default 5000 = $50)
10
+ * COMMERCEFORAI_READ_ONLY "true" exposes only read tools
11
+ * COMMERCEFORAI_TOOLS optional comma-separated allowlist of tool names
12
+ */
13
+ import { Commerce } from "commerceforai";
14
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ import { z } from "zod";
17
+ export function configFromEnv(env = process.env) {
18
+ return {
19
+ maxRefund: env.COMMERCEFORAI_MAX_REFUND !== undefined ? Number(env.COMMERCEFORAI_MAX_REFUND) : 5000,
20
+ maxLabelCost: env.COMMERCEFORAI_MAX_LABEL_COST !== undefined ? Number(env.COMMERCEFORAI_MAX_LABEL_COST) : 5000,
21
+ readOnly: env.COMMERCEFORAI_READ_ONLY === "true",
22
+ allow: env.COMMERCEFORAI_TOOLS ? new Set(env.COMMERCEFORAI_TOOLS.split(",").map((s) => s.trim())) : null,
23
+ };
24
+ }
25
+ const text = (value, isError = false) => ({
26
+ content: [{ type: "text", text: typeof value === "string" ? value : JSON.stringify(value, null, 2) }],
27
+ ...(isError ? { isError: true } : {}),
28
+ });
29
+ function out(r) {
30
+ return r.error ? text(`Error (${r.error.type}): ${r.error.message}${r.error.param ? ` [param: ${r.error.param}]` : ""}\n${JSON.stringify(r.error)}`, true) : text(r.data);
31
+ }
32
+ const money = (cents, currency = "usd") => new Intl.NumberFormat("en-US", { style: "currency", currency }).format(cents / 100);
33
+ export function buildServer(commerce, cfg) {
34
+ const server = new McpServer({ name: "commerceforai", version: "0.1.0" });
35
+ const tool = (name, def, run) => {
36
+ if (cfg.allow && !cfg.allow.has(name))
37
+ return;
38
+ if (def.write && cfg.readOnly)
39
+ return;
40
+ server.registerTool(name, {
41
+ description: def.description,
42
+ inputSchema: def.input,
43
+ annotations: { readOnlyHint: !def.write, destructiveHint: !!def.destructive, openWorldHint: false },
44
+ }, (async (args) => {
45
+ try {
46
+ return await run(args);
47
+ }
48
+ catch (e) {
49
+ return text(`Unexpected error: ${e instanceof Error ? e.message : e}`, true);
50
+ }
51
+ }));
52
+ };
53
+ // ---------- read ----------
54
+ tool("get_store", { description: "Store settings: name, currency, test/live mode, whether Stripe events are connected. If stripe_branding.matches is false, tell the owner what customers see and the suggested values; only they can change it in Stripe (fix_url).", input: {} }, async () => out(await commerce.store.get()));
55
+ tool("chatgpt_channel", {
56
+ description: "Sell in ChatGPT: readiness steps (live store, products, storefront links, feed passes OpenAI's rules), the answers to give on OpenAI's merchant application, and the application status. The owner submits the application themselves at chatgpt.com/merchants (it asks for their personal details).",
57
+ input: {},
58
+ }, async () => out(await commerce.store.chatgpt()));
59
+ tool("update_chatgpt_channel", {
60
+ description: "Record ChatGPT application progress after the owner tells you (status: applied, approved, not_approved), or set the product category to apply with.",
61
+ input: { status: z.enum(["not_applied", "applied", "approved", "not_approved"]).optional(), category: z.string().optional(), note: z.string().optional() },
62
+ write: true,
63
+ }, async (a) => out(await commerce.store.updateChatgpt(a)));
64
+ tool("check_product_feed", {
65
+ description: "Would ChatGPT shopping (OpenAI's product feed spec) accept this store's catalog? Returns per-item errors (would be rejected: e.g. missing JPEG/PNG image, no product URL) and warnings (weaker listings), so you can fix them before the owner applies at chatgpt.com/merchants.",
66
+ input: {},
67
+ }, async () => out(await commerce.store.checkFeed()));
68
+ tool("setup_status", {
69
+ description: "Start here. Everything between this store and real orders, each with status (done/todo/warning/optional) and the exact fix; plus ready_for_real_orders and next_step.",
70
+ input: {},
71
+ }, async () => out(await commerce.store.setupStatus()));
72
+ tool("simulate_order", {
73
+ description: "Test stores only: run a purchase end to end without a browser to prove the integration works. Reserves and commits stock, creates a paid order, fires events and the app's webhooks; fulfill: true also marks it shipped. Returns the events, webhook deliveries and the emails a real order would send. Never charges a card, reaches Printify/Printful, emails anyone, or counts toward usage.",
74
+ input: {
75
+ items: z.array(z.object({ variant_id: z.string(), quantity: z.number().int().min(1), properties: z.record(z.string(), z.string()).optional() })).min(1),
76
+ customer_email: z.string().email().optional(),
77
+ discount_code: z.string().optional().describe("Apply a discount code, as a shopper would"),
78
+ fulfill: z.boolean().optional(),
79
+ shipping_rate: z.object({ name: z.string(), amount: z.number().int().min(0) }).optional(),
80
+ },
81
+ write: true,
82
+ }, async (a) => out(await commerce.store.simulateOrder(a)));
83
+ tool("configure_checkout_shipping", {
84
+ description: "Set the shipping every checkout offers (unless a checkout passes its own): up to 5 flat rates in cents with optional delivery days, the countries the store ships to, and an optional free-shipping threshold on the item subtotal in cents. Without this, checkouts charge $0 shipping.",
85
+ input: {
86
+ countries: z.array(z.string().length(2)).min(1),
87
+ rates: z.array(z.object({ name: z.string(), amount: z.number().int().min(0), min_days: z.number().int().positive().optional(), max_days: z.number().int().positive().optional() })).min(1).max(5),
88
+ free_over: z.number().int().min(0).nullable().optional(),
89
+ },
90
+ write: true,
91
+ }, async (a) => out(await commerce.store.configureCheckoutShipping(a)));
92
+ tool("import_from_shopify", {
93
+ description: "Move products from Shopify: pass shop (your-store.myshopify.com) and a custom app access_token with read_products and read_inventory, or csv with the contents of Shopify's product export (Products → Export). Imports products, variants (options, SKUs, prices), stock, images and descriptions. Safe to re-run: matches by Shopify id or SKU and keeps existing prices and stock.",
94
+ input: { shop: z.string().optional(), access_token: z.string().optional(), csv: z.string().optional() },
95
+ write: true,
96
+ }, async (a) => out(await commerce.store.importFromShopify((a.csv ? { csv: a.csv } : { shop: a.shop ?? "", access_token: a.access_token ?? "" }))));
97
+ tool("import_catalog", {
98
+ description: "Import the products already set up in the connected Printify shop or Printful store (both if provider is omitted). Happens automatically when a provider is connected; safe to re-run. Runs in the background: check get_store → fulfillment[].catalog_import for progress.",
99
+ input: { provider: z.enum(["printful", "printify"]).optional() },
100
+ write: true,
101
+ }, async (a) => out(await commerce.store.importCatalog(a)));
102
+ tool("create_go_live_link", {
103
+ description: "When the store is ready for real orders, create a one-time link to give the person. Opening it signs them in, claims the store if needed, creates a live copy with the same products and settings, and takes them to Connect Stripe. Test stores only; the link lasts 7 days.",
104
+ input: {},
105
+ write: true,
106
+ }, async () => out(await commerce.store.goLiveLink()));
107
+ tool("list_products", {
108
+ description: "List products with their variants, prices (in cents) and stock. Pass collection (id or handle) to list one collection's products.",
109
+ input: { limit: z.number().int().min(1).max(100).optional(), starting_after: z.string().optional(), active: z.boolean().optional(), collection: z.string().optional(), q: z.string().optional() },
110
+ }, async (a) => out(await commerce.products.list(a)));
111
+ tool("get_product", { description: "Get one product by id.", input: { product_id: z.string() } }, async (a) => out(await commerce.products.get(a.product_id)));
112
+ tool("list_inventory", {
113
+ description: "Tracked variants sorted by available stock (lowest first). Use low_stock_threshold to only see items at or below it.",
114
+ input: { low_stock_threshold: z.number().int().optional() },
115
+ }, async (a) => out(await commerce.inventory.list(a)));
116
+ tool("list_orders", {
117
+ description: "List orders, newest first. Filter by status: pending_payment, paid (ready to ship), fulfilled, cancelled, refunded.",
118
+ input: {
119
+ status: z.array(z.enum(["pending_payment", "paid", "fulfilled", "cancelled", "refunded"])).optional(),
120
+ limit: z.number().int().min(1).max(100).optional(),
121
+ starting_after: z.string().optional(),
122
+ },
123
+ }, async (a) => out(await commerce.orders.list(a)));
124
+ tool("get_order", { description: "Get an order by id (ord_...) or number (e.g. 1001).", input: { order: z.string() } }, async (a) => out(await commerce.orders.get(a.order)));
125
+ tool("list_events", {
126
+ description: "Read the store's event log in order (order.created, order.paid, inventory.updated, ...). Pass the last seen event id as `after` to get only new events.",
127
+ input: { after: z.string().optional(), type: z.string().optional(), limit: z.number().int().min(1).max(100).optional() },
128
+ }, async (a) => out(await commerce.events.list(a)));
129
+ // ---------- write ----------
130
+ tool("create_product", {
131
+ description: "Create a product. Prices are in cents. For sizes/colors pass `variants`; otherwise pass `price` (and optionally `inventory`). Omit inventory for unlimited stock.",
132
+ write: true,
133
+ input: {
134
+ name: z.string(),
135
+ description: z.string().optional(),
136
+ images: z.array(z.string().url()).optional(),
137
+ price: z.number().int().nonnegative().optional(),
138
+ inventory: z.number().int().nonnegative().optional(),
139
+ sku: z.string().optional(),
140
+ variants: z.array(z.object({
141
+ name: z.string(), price: z.number().int().nonnegative(), sku: z.string().optional(),
142
+ inventory: z.number().int().nonnegative().optional(), options: z.record(z.string(), z.string()).optional(),
143
+ })).optional(),
144
+ },
145
+ }, async (a) => out(await commerce.products.create(a)));
146
+ tool("create_products", {
147
+ description: "Create up to 100 products in one call (much faster than create_product in a loop). Same fields as create_product, plus optional metadata and print-on-demand `fulfillment` per variant. Each product succeeds or fails on its own; the result lists failures by index so you can fix and resend just those.",
148
+ write: true,
149
+ input: {
150
+ products: z.array(z.object({
151
+ name: z.string(), description: z.string().optional(), images: z.array(z.string().url()).optional(),
152
+ metadata: z.record(z.string(), z.string()).optional(),
153
+ price: z.number().int().nonnegative().optional(), inventory: z.number().int().nonnegative().optional(), sku: z.string().optional(),
154
+ print_files: z.array(z.object({ placement: z.string(), url: z.string().url() })).optional(),
155
+ variants: z.array(z.object({
156
+ name: z.string(), price: z.number().int().nonnegative(), sku: z.string().optional(),
157
+ inventory: z.number().int().nonnegative().optional(), options: z.record(z.string(), z.string()).optional(),
158
+ fulfillment: z.record(z.string(), z.unknown()).optional(),
159
+ })).optional(),
160
+ })).min(1).max(100),
161
+ },
162
+ }, async (a) => out(await commerce.products.createMany(a.products)));
163
+ tool("printify_catalog", {
164
+ description: "Look up Printify's catalog through the store's connected Printify shop (no token needed). Step 1: pass q (e.g. \"heavy cotton tee\") to find blueprint ids. Step 2: pass blueprint_id to list print providers. Step 3: pass blueprint_id + print_provider_id to get variant ids, option values (color, size) and placements (front, back…).",
165
+ input: { q: z.string().optional(), blueprint_id: z.number().int().optional(), print_provider_id: z.number().int().optional() },
166
+ }, async (a) => {
167
+ if (a.blueprint_id && a.print_provider_id)
168
+ return out(await commerce.printify.variants(a.blueprint_id, a.print_provider_id));
169
+ if (a.blueprint_id)
170
+ return out(await commerce.printify.providers(a.blueprint_id));
171
+ return out(await commerce.printify.blueprints({ q: a.q }));
172
+ });
173
+ tool("create_in_printify", {
174
+ description: "Create one of the store's products in its Printify shop: uploads the artwork, enables and prices each variant, generates mockups, and maps the variants so paid orders go straight to Printify. Variants are matched by option values; use fixed_options for options the product doesn't have (e.g. { color: \"Black\" } for a size-only tee) or variant_map ({ our_variant_id: printify_variant_id }). Prices must cover Printify's cost. Run once per product.",
175
+ write: true,
176
+ input: {
177
+ product_id: z.string(), blueprint_id: z.number().int(), print_provider_id: z.number().int(),
178
+ print_files: z.array(z.object({ placement: z.string(), url: z.string().url() })).optional(),
179
+ fixed_options: z.record(z.string(), z.string()).optional(),
180
+ variant_map: z.record(z.string(), z.number().int()).optional(),
181
+ use_mockups: z.boolean().optional(),
182
+ },
183
+ }, async ({ product_id, ...rest }) => out(await commerce.printify.createProduct(product_id, rest)));
184
+ tool("upload_image", {
185
+ description: "Host an image on cdn.commerceforai.com (PNG, JPEG or WebP, up to 25 MB) and get a permanent URL to use for product images, print_files or the email logo. Pass url to copy from a link, or base64 data for a local file. Photo metadata is removed; the same image is stored once.",
186
+ write: true,
187
+ input: { url: z.string().url().optional(), data: z.string().optional().describe("base64 file contents (for local files)"), filename: z.string().optional() },
188
+ }, async (a) => {
189
+ if (!a.url && !a.data)
190
+ return text("Pass url or data.", true);
191
+ return out(a.url ? await commerce.files.upload({ url: a.url }) : await commerce.files.upload(Uint8Array.from(Buffer.from(a.data, "base64")), { filename: a.filename }));
192
+ });
193
+ tool("copy_product_images", {
194
+ description: "Copy products' images and print files from outside links onto cdn.commerceforai.com and point the products at the copies, so a moved or deleted source can't break the store. Pass product_id for one product; otherwise it does the next batch (repeat until remaining is 0).",
195
+ write: true,
196
+ input: { product_id: z.string().optional(), limit: z.number().int().min(1).max(50).optional() },
197
+ }, async (a) => a.product_id
198
+ ? out(await commerce.files.copyProductImages(a.product_id))
199
+ : out(await commerce.files.copyAllProductImages({ limit: a.limit })));
200
+ tool("preview_store_sync", {
201
+ description: "Preview syncing this store's other version (test or live) into this store: products to add or change, price changes, collections, discounts, settings. Changes nothing. Syncs never touch orders, keys, Stripe, site URLs or stock counts, and leave anything only in this store alone. Show the owner the plan before applying into a live store.",
202
+ input: {},
203
+ }, async () => out(await commerce.store.syncPreview()));
204
+ tool("apply_store_sync", {
205
+ description: "Apply a sync into the store this connection belongs to. Into a live store it needs plan_id from preview_store_sync and changes what real customers see and pay, so get the owner's OK on the plan first.",
206
+ write: true,
207
+ input: { plan_id: z.string().optional() },
208
+ }, async (a) => out(await commerce.store.sync({ plan_id: a.plan_id })));
209
+ tool("update_product", {
210
+ description: "Update a product's name, description, images, or set active=false to stop selling it.",
211
+ write: true,
212
+ input: { product_id: z.string(), name: z.string().optional(), description: z.string().optional(), images: z.array(z.string().url()).optional(), active: z.boolean().optional() },
213
+ }, async ({ product_id, ...rest }) => out(await commerce.products.update(product_id, rest)));
214
+ tool("update_variant", {
215
+ description: "Change a variant's price (cents), name, SKU, or set active=false to stop selling it.",
216
+ write: true,
217
+ input: { variant_id: z.string(), price: z.number().int().nonnegative().optional(), name: z.string().optional(), sku: z.string().optional(), active: z.boolean().optional() },
218
+ }, async ({ variant_id, ...rest }) => out(await commerce.variants.update(variant_id, rest)));
219
+ tool("adjust_inventory", {
220
+ description: "Change stock for a variant. Use `adjust` for a relative change (+24 after a delivery, -1 for a damaged unit) or `set` for an absolute count after a stocktake. Always give a reason.",
221
+ write: true,
222
+ input: { variant_id: z.string(), adjust: z.number().int().optional(), set: z.number().int().nonnegative().optional(), reason: z.string() },
223
+ }, async (a) => {
224
+ if ((a.adjust === undefined) === (a.set === undefined))
225
+ return text("Provide exactly one of `adjust` or `set`.", true);
226
+ return out(a.set !== undefined ? await commerce.inventory.set(a.variant_id, a.set, a.reason) : await commerce.inventory.adjust(a.variant_id, a.adjust, a.reason));
227
+ });
228
+ tool("create_checkout", {
229
+ description: "Create a Stripe-hosted checkout link for a customer. Stock is held until it expires. Returns `url` to send to the customer.",
230
+ write: true,
231
+ input: {
232
+ items: z.array(z.object({
233
+ variant_id: z.string(), quantity: z.number().int().min(1),
234
+ properties: z.record(z.string(), z.string()).optional().describe("Custom data for this line, e.g. { name: 'Riley' }"),
235
+ print_files: z.array(z.object({ placement: z.string(), url: z.string().url() })).optional(),
236
+ })).min(1),
237
+ customer_email: z.string().email().optional(),
238
+ success_url: z.string().url().optional(),
239
+ discount_code: z.string().optional().describe("Apply this code up front. Otherwise the customer can enter any live code on Stripe's page."),
240
+ },
241
+ }, async (a) => out(await commerce.checkouts.create(a)));
242
+ tool("fulfill_order", {
243
+ description: "Mark a paid order as shipped, with optional carrier and tracking. Only do this once the package has actually been handed to a carrier.",
244
+ write: true,
245
+ input: { order: z.string(), carrier: z.string().optional(), tracking_number: z.string().optional(), tracking_url: z.string().url().optional() },
246
+ }, async ({ order, ...rest }) => out(await commerce.orders.fulfill(order, rest)));
247
+ tool("submit_fulfillment", {
248
+ description: "Send a paid order to the print-on-demand provider (Printful or Printify) now — e.g. after fixing a missing print file. Orders are normally sent automatically when paid.",
249
+ write: true,
250
+ input: { order: z.string() },
251
+ }, async (a) => out(await commerce.orders.submitFulfillment(a.order)));
252
+ tool("add_order_note", {
253
+ description: "Attach an internal note to an order (not shown to the customer).",
254
+ write: true,
255
+ input: { order: z.string(), note: z.string() },
256
+ }, async (a) => {
257
+ const o = await commerce.orders.get(a.order);
258
+ if (o.error)
259
+ return out(o);
260
+ return out(await commerce.orders.update(o.data.id, { note: a.note }));
261
+ });
262
+ tool("refund_order", {
263
+ description: `Refund an order through Stripe. Omit amount for the full remaining amount. This tool is limited to ${money(cfg.maxRefund)} per refund; larger refunds must be done by a human. Set restock=true if the items came back sellable.`,
264
+ write: true,
265
+ destructive: true,
266
+ input: {
267
+ order: z.string(),
268
+ amount: z.number().int().positive().optional(),
269
+ restock: z.boolean().optional(),
270
+ reason: z.enum(["duplicate", "fraudulent", "requested_by_customer"]).optional(),
271
+ },
272
+ }, async (a) => {
273
+ const o = await commerce.orders.get(a.order);
274
+ if (o.error)
275
+ return out(o);
276
+ const amount = a.amount ?? o.data.total - o.data.refunded;
277
+ if (amount > cfg.maxRefund) {
278
+ return text(`Refused by guardrail: refunding ${money(amount, o.data.currency)} on order #${o.data.number} exceeds this agent's limit of ` +
279
+ `${money(cfg.maxRefund, o.data.currency)} (COMMERCEFORAI_MAX_REFUND). Ask a human to approve it, or refund a smaller amount.`, true);
280
+ }
281
+ return out(await commerce.orders.refund(o.data.id, { amount, restock: a.restock, reason: a.reason }));
282
+ });
283
+ // ---------- shipping & returns ----------
284
+ const maxLabel = cfg.maxLabelCost ?? 5000;
285
+ // ---------- collections ----------
286
+ tool("list_collections", { description: "Collections (product groups like 'Mugs' or 'Yosemite') in display order, with their product ids. Filter products by one with list_products collection.", input: {} }, async () => out(await commerce.collections.list()));
287
+ tool("save_collection", {
288
+ description: "Create a collection, or update one by id/handle. product_ids replaces its membership in display order; omit it to leave products as they are. The handle defaults from the name.",
289
+ write: true,
290
+ input: {
291
+ collection: z.string().optional().describe("Id or handle of an existing collection to update; omit to create"),
292
+ name: z.string().optional(), handle: z.string().optional(), description: z.string().optional(), image: z.string().url().optional(),
293
+ product_ids: z.array(z.string()).optional(), position: z.number().int().optional(),
294
+ },
295
+ }, async ({ collection, ...p }) => out(collection ? await commerce.collections.update(collection, p) : await commerce.collections.create({ ...p, name: p.name ?? "" })));
296
+ tool("delete_collection", { description: "Delete a collection. The products themselves are kept.", write: true, destructive: true, input: { collection: z.string() } }, async ({ collection }) => out(await commerce.collections.delete(collection)));
297
+ // ---------- customers & reports ----------
298
+ tool("list_customers", {
299
+ description: "Customers who've paid, matched by email, with order count and total spent (after refunds). Search by email or name; sort recent, spent or orders.",
300
+ input: { q: z.string().optional(), sort: z.enum(["recent", "spent", "orders"]).optional(), limit: z.number().int().min(1).max(100).optional(), page: z.number().int().min(1).optional() },
301
+ }, async (a) => out(await commerce.customers.list(a)));
302
+ tool("get_customer", { description: "One customer by email: totals, last shipping address and order history.", input: { email: z.string() } }, async ({ email }) => out(await commerce.customers.get(email)));
303
+ tool("sales_report", {
304
+ description: "Sales over the last N days (default 30): orders, gross, net after refunds, discounts, average order, new vs returning customers, change vs the previous period, a time series and top products. Amounts are cents. Simulated orders are excluded.",
305
+ input: { days: z.number().int().min(1).max(366).optional() },
306
+ }, async (a) => out(await commerce.reports.sales(a)));
307
+ // ---------- discounts ----------
308
+ tool("list_discounts", { description: "Discount codes with status (live, scheduled, expired, used_up, off) and times used.", input: {} }, async () => out(await commerce.discounts.list()));
309
+ tool("save_discount", {
310
+ description: "Create a discount code, or update one by id/code. kind percent (value 1–100) or amount (value in cents). Optional minimum order (cents), usage limit and start/end dates. Live codes work at checkout right away: customers enter them on Stripe's page, or pass discount_code to create_checkout. Confirm the terms with the owner before creating a code.",
311
+ write: true,
312
+ input: {
313
+ discount: z.string().optional().describe("Id or code of an existing discount to update; omit to create"),
314
+ code: z.string().optional(), kind: z.enum(["percent", "amount"]).optional(), value: z.number().int().positive().optional(),
315
+ min_subtotal: z.number().int().min(0).nullable().optional(), max_redemptions: z.number().int().min(1).nullable().optional(),
316
+ starts_at: z.string().nullable().optional(), ends_at: z.string().nullable().optional(), active: z.boolean().optional(),
317
+ },
318
+ }, async ({ discount, ...p }) => out(discount ? await commerce.discounts.update(discount, p) : await commerce.discounts.create(p)));
319
+ tool("delete_discount", { description: "Delete a discount code. It stops working at checkout immediately. To pause it instead, save_discount with active: false.", write: true, destructive: true, input: { discount: z.string() } }, async ({ discount }) => out(await commerce.discounts.delete(discount)));
320
+ // ---------- email branding ----------
321
+ tool("brand_emails", {
322
+ description: "Brand the customer emails (order confirmation, shipped, refund, return, cart reminder): an https logo URL, an accent hex color for buttons, and the address shown in the footer. Also sets team alerts: new_order (an email per sale) and low_stock_threshold (email when a variant's stock falls to it; null = off). Then use send_test_emails so the owner can see them.",
323
+ write: true,
324
+ input: {
325
+ logo_url: z.string().url().nullable().optional(), accent_color: z.string().nullable().optional(), address: z.string().nullable().optional(),
326
+ new_order_alerts: z.boolean().optional(), low_stock_threshold: z.number().int().min(0).nullable().optional(),
327
+ },
328
+ }, async (a) => out(await commerce.store.update({
329
+ email_brand: { ...(a.logo_url !== undefined ? { logo_url: a.logo_url } : {}), ...(a.accent_color !== undefined ? { accent_color: a.accent_color } : {}), ...(a.address !== undefined ? { address: a.address } : {}) },
330
+ owner_alerts: { ...(a.new_order_alerts !== undefined ? { new_order: a.new_order_alerts } : {}), ...(a.low_stock_threshold !== undefined ? { low_stock_threshold: a.low_stock_threshold } : {}) },
331
+ })));
332
+ tool("send_test_emails", {
333
+ description: "Email the store owner a preview of every customer email (or one template) with the store's branding and sample data from its products. Only goes to the store's own members.",
334
+ write: true,
335
+ input: { template: z.enum(["all", "order_confirmation", "order_shipped", "order_refunded", "return_approved", "checkout_recovery"]).optional() },
336
+ }, async (a) => out(await commerce.emails.sendTest({ template: a.template ?? "all" })));
337
+ tool("list_returns", {
338
+ description: "List returns, newest first. Status: requested (needs a decision), approved (waiting for items), rejected, completed.",
339
+ input: { status: z.enum(["requested", "approved", "rejected", "completed"]).optional(), order: z.string().optional() },
340
+ }, async (a) => out(await commerce.returns.list({ status: a.status, order_id: a.order })));
341
+ tool("get_shipping_rates", {
342
+ description: "Get live shipping label rates for a paid order (cheapest first). Parcel weight comes from variant weights unless you pass parcel. Pass return_id for return-label rates.",
343
+ input: {
344
+ order: z.string(),
345
+ return_id: z.string().optional(),
346
+ parcel: z.object({ length: z.number().optional(), width: z.number().optional(), height: z.number().optional(), weight_oz: z.number().optional() }).optional(),
347
+ },
348
+ }, async ({ order, ...rest }) => out(await commerce.orders.shippingRates(order, rest)));
349
+ tool("buy_shipping_label", {
350
+ description: `Buy a shipping label for a rate from get_shipping_rates. This charges the merchant's carrier account and marks the order shipped (the customer gets a tracking email). Limited to ${money(maxLabel)} per label.`,
351
+ write: true,
352
+ destructive: true,
353
+ input: { order: z.string(), rate_id: z.string() },
354
+ }, async (a) => {
355
+ // The server checks the stored rate's real price against max_cost before buying.
356
+ const res = await commerce.orders.buyLabel(a.order, a.rate_id, { max_cost: maxLabel });
357
+ if (res.error?.param === "max_cost")
358
+ return text(`Refused by guardrail: that label costs ${money(Number(res.error.amount))}, over this agent's ${money(maxLabel)} limit ` +
359
+ `(COMMERCEFORAI_MAX_LABEL_COST). Choose a cheaper rate or ask a human.`, true);
360
+ return out(res);
361
+ });
362
+ tool("create_return", {
363
+ description: "Open a return for items on a shipped order.",
364
+ write: true,
365
+ input: { order: z.string(), items: z.array(z.object({ variant_id: z.string(), quantity: z.number().int().min(1) })).min(1), reason: z.string().optional() },
366
+ }, async ({ order, ...rest }) => out(await commerce.returns.create(order, rest)));
367
+ tool("approve_return", {
368
+ description: "Approve a requested return. Set prepaid_label=true to buy the cheapest return label and email it to the customer.",
369
+ write: true,
370
+ input: { return_id: z.string(), prepaid_label: z.boolean().optional(), note: z.string().optional() },
371
+ }, async (a) => out(await commerce.returns.approve(a.return_id, { note: a.note, ...(a.prepaid_label ? { return_label: "cheapest" } : {}) })));
372
+ tool("reject_return", {
373
+ description: "Reject a return request, with a note explaining why.",
374
+ write: true,
375
+ input: { return_id: z.string(), note: z.string() },
376
+ }, async (a) => out(await commerce.returns.reject(a.return_id, { note: a.note })));
377
+ tool("receive_return", {
378
+ description: `Record that returned items arrived. Restocks them (unless restock=false) and refunds their value through Stripe (unless refund=false). The refund is subject to the ${money(cfg.maxRefund)} per-refund limit.`,
379
+ write: true,
380
+ destructive: true,
381
+ input: { return_id: z.string(), restock: z.boolean().optional(), refund: z.boolean().optional() },
382
+ }, async (a) => {
383
+ const r = await commerce.returns.get(a.return_id);
384
+ if (r.error)
385
+ return out(r);
386
+ const value = r.data.items.reduce((s, i) => s + i.unit_price * i.quantity, 0);
387
+ if ((a.refund ?? true) && value > cfg.maxRefund)
388
+ return text(`Refused by guardrail: this return would refund ${money(value, r.data.currency ?? "usd")}, over this agent's ${money(cfg.maxRefund)} limit. ` +
389
+ `Ask a human, or call with refund=false to restock only.`, true);
390
+ return out(await commerce.returns.receive(a.return_id, { restock: a.restock, refund: a.refund }));
391
+ });
392
+ return server;
393
+ }
394
+ // Run as a stdio server when executed directly.
395
+ const isMain = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("commerceforai-mcp");
396
+ if (isMain) {
397
+ const commerce = new Commerce(process.env.COMMERCEFORAI_API_KEY);
398
+ const server = buildServer(commerce, configFromEnv());
399
+ await server.connect(new StdioServerTransport());
400
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@commerceforai/mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.1.0",
4
+ "description": "MCP server that lets AI agents operate a CommerceForAI store, with guardrails.",
5
+ "type": "module",
6
+ "bin": {
7
+ "commerceforai-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "license": "MIT",
13
+ "dependencies": {
14
+ "@modelcontextprotocol/sdk": "^1.32.2",
15
+ "zod": "^4.6.7",
16
+ "commerceforai": "0.1.0"
17
+ },
18
+ "main": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "default": "./dist/index.js"
24
+ }
25
+ },
26
+ "homepage": "https://commerceforai.com",
27
+ "keywords": [
28
+ "mcp",
29
+ "model-context-protocol",
30
+ "commerce",
31
+ "ecommerce",
32
+ "stripe",
33
+ "ai",
34
+ "agents"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ },
39
+ "scripts": {
40
+ "build": "tsc -p tsconfig.json",
41
+ "typecheck": "tsc -p tsconfig.json --noEmit"
42
+ }
6
43
  }