@reynandaptr/replyto-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 ADDED
@@ -0,0 +1,30 @@
1
+ # @reynandaptr/replyto-mcp
2
+
3
+ MCP server for [Replyto](https://replyto.id) — manage your products and
4
+ Instagram automations from any MCP-capable AI client.
5
+
6
+ ## Setup
7
+
8
+ 1. In the Replyto dashboard, open **Settings → API Keys** (paid plans) and
9
+ create a key.
10
+ 2. Add to your AI client's MCP config:
11
+
12
+ ```json
13
+ {
14
+ "mcpServers": {
15
+ "replyto": {
16
+ "command": "npx",
17
+ "args": ["-y", "@reynandaptr/replyto-mcp"],
18
+ "env": { "REPLYTO_API_KEY": "rt_..." }
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ `REPLYTO_URL` (optional) overrides the API base URL for staging/local use.
25
+
26
+ ## Tools
27
+
28
+ Products: list, get, create, update, delete, replace links.
29
+ Automations: list, get, create, update, toggle, delete, preview conflicts, list runs.
30
+ Lookups: stores, Instagram accounts, Instagram media.
package/dist/client.js ADDED
@@ -0,0 +1,63 @@
1
+ // Thin fetch wrapper for the Replyto API. Every MCP tool is a one-line
2
+ // call through this — the API stays the validator of record, and coded
3
+ // error bodies are surfaced verbatim so the AI can self-correct.
4
+ export const ok = (text) => ({ content: [{ type: "text", text }] });
5
+ export const err = (text) => ({ content: [{ type: "text", text }], isError: true });
6
+ // One-line remediation hints appended to the coded errors an AI can't fix
7
+ // by changing its input.
8
+ const HINTS = {
9
+ unauthorized: "Check that REPLYTO_API_KEY is a valid, non-revoked Replyto API key.",
10
+ plan_upgrade_required: "API access requires a paid Replyto plan — upgrade in the Replyto dashboard.",
11
+ email_unverified: "Verify your email address in the Replyto dashboard first.",
12
+ };
13
+ export class ReplytoClient {
14
+ apiKey;
15
+ baseUrl;
16
+ constructor(baseUrl, apiKey) {
17
+ this.apiKey = apiKey;
18
+ this.baseUrl = baseUrl.replace(/\/+$/, "");
19
+ }
20
+ async call(method, path, opts = {}) {
21
+ const url = new URL(this.baseUrl + path);
22
+ for (const [k, v] of Object.entries(opts.query ?? {})) {
23
+ if (v !== undefined && v !== null && v !== "")
24
+ url.searchParams.set(k, String(v));
25
+ }
26
+ let res;
27
+ try {
28
+ res = await fetch(url, {
29
+ method,
30
+ headers: {
31
+ Authorization: `Bearer ${this.apiKey}`,
32
+ ...(opts.body !== undefined ? { "Content-Type": "application/json" } : {}),
33
+ },
34
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
35
+ });
36
+ }
37
+ catch (e) {
38
+ return err(`network error calling ${method} ${path}: ${e instanceof Error ? e.message : String(e)}`);
39
+ }
40
+ if (res.status === 204)
41
+ return ok("done");
42
+ const raw = await res.text();
43
+ let data = null;
44
+ try {
45
+ data = raw ? JSON.parse(raw) : null;
46
+ }
47
+ catch {
48
+ // non-JSON body — fall through with raw text
49
+ }
50
+ if (!res.ok) {
51
+ const body = (data ?? {});
52
+ const code = body.code ?? `http_${res.status}`;
53
+ let text = `${code}: ${body.message ?? (raw || res.statusText)}`;
54
+ if (HINTS[code])
55
+ text += `\n${HINTS[code]}`;
56
+ if (body.conflicts !== undefined) {
57
+ text += `\nconflicts: ${JSON.stringify(body.conflicts, null, 2)}`;
58
+ }
59
+ return err(text);
60
+ }
61
+ return ok(data === null ? "done" : JSON.stringify(data, null, 2));
62
+ }
63
+ }
package/dist/config.js ADDED
@@ -0,0 +1,2 @@
1
+ // Generated by scripts/gen-config.mjs at build time — do not edit or commit.
2
+ export const DEFAULT_BASE_URL = "https://api.replyto.id";
package/dist/index.js ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env node
2
+ // @reynandaptr/replyto-mcp — stdio MCP server exposing Replyto products & automations.
3
+ // Config via env: REPLYTO_API_KEY (required), REPLYTO_URL (optional).
4
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
5
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
6
+ import { ReplytoClient } from "./client.js";
7
+ import { DEFAULT_BASE_URL } from "./config.js";
8
+ import { productTools } from "./tools/products.js";
9
+ import { automationTools } from "./tools/automations.js";
10
+ import { lookupTools } from "./tools/lookups.js";
11
+ const apiKey = process.env.REPLYTO_API_KEY;
12
+ if (!apiKey) {
13
+ console.error("REPLYTO_API_KEY is not set.\n" +
14
+ "Create an API key in the Replyto dashboard (Settings → API Keys) and add it to this MCP server's env config.");
15
+ process.exit(1);
16
+ }
17
+ const client = new ReplytoClient(process.env.REPLYTO_URL ?? DEFAULT_BASE_URL, apiKey);
18
+ const server = new McpServer({ name: "replyto", version: "0.1.0" });
19
+ for (const def of [...productTools, ...automationTools, ...lookupTools]) {
20
+ server.registerTool(def.name, { description: def.description, inputSchema: def.inputSchema, annotations: def.annotations }, (args) => def.handler(client, args ?? {}));
21
+ }
22
+ try {
23
+ await server.connect(new StdioServerTransport());
24
+ }
25
+ catch (e) {
26
+ console.error(`failed to start replyto MCP server: ${e instanceof Error ? e.message : String(e)}`);
27
+ process.exit(1);
28
+ }
@@ -0,0 +1,100 @@
1
+ import { z } from "zod";
2
+ // Mirrors automations.AutomationAccountInput. post_id is only meaningful
3
+ // with post_scope=specific.
4
+ const accountInput = {
5
+ handle: z.string().describe("Instagram handle of a connected account (see list_instagram_accounts)"),
6
+ post_id: z.string().optional().describe("IG media id — required per account when post_scope=specific (see list_instagram_media)"),
7
+ };
8
+ const ruleFields = {
9
+ name: z.string().optional().describe("Optional label; the API derives one when omitted"),
10
+ trigger: z.enum(["keyword", "all"]).describe("keyword = fire only when the comment/DM contains `keyword`; all = fire on every comment"),
11
+ keyword: z.string().optional().describe("Required when trigger=keyword. Case-insensitive match."),
12
+ action: z.enum(["reply", "dm", "dm_store", "dm_product"]).describe("reply = public comment reply; dm = send `message` as a DM; dm_store = DM a store link (needs store_id); dm_product = DM a product link (needs product_id)"),
13
+ message: z.string().optional().describe("The reply/DM text"),
14
+ accounts: z.array(z.object(accountInput)).describe("Which connected IG accounts this rule runs on"),
15
+ post_scope: z.enum(["specific", "next", "all", "dm", "live"]).describe("specific = only the given post_id(s); next = the next post published after creating the rule; all = every post; dm = incoming DMs instead of comments; live = live-video comments"),
16
+ store_id: z.string().uuid().optional().describe("Required when action=dm_store (see list_stores)"),
17
+ product_id: z.string().uuid().optional().describe("Required when action=dm_product (see list_products)"),
18
+ reply_also: z.boolean().optional().describe("For DM actions: also leave a short public reply"),
19
+ reply_text: z.string().optional().describe("The public reply text when reply_also=true"),
20
+ on: z.boolean().optional().describe("Start enabled (default true)"),
21
+ };
22
+ export const automationTools = [
23
+ {
24
+ name: "list_automations",
25
+ description: "List the user's Instagram automation rules. Filters: account (IG handle), on (enabled), scope, page, limit.",
26
+ inputSchema: {
27
+ account: z.string().optional(),
28
+ on: z.boolean().optional(),
29
+ scope: z.enum(["specific", "next", "all", "dm", "live"]).optional(),
30
+ page: z.number().int().min(1).optional(),
31
+ limit: z.number().int().min(1).optional(),
32
+ },
33
+ annotations: { readOnlyHint: true },
34
+ handler: (c, a) => c.call("GET", "/automations", { query: a }),
35
+ },
36
+ {
37
+ name: "get_automation",
38
+ description: "Fetch one automation rule by id.",
39
+ inputSchema: { id: z.string().uuid() },
40
+ annotations: { readOnlyHint: true },
41
+ handler: (c, { id }) => c.call("GET", `/automations/${id}`),
42
+ },
43
+ {
44
+ name: "create_automation",
45
+ description: "Create an Instagram auto-reply/DM rule. On a 409 the response lists conflicting rules — " +
46
+ "adjust keyword/scope/accounts or ask the user. Consider preview_automation_conflicts first.",
47
+ inputSchema: ruleFields,
48
+ handler: (c, a) => c.call("POST", "/automations", { body: a }),
49
+ },
50
+ {
51
+ name: "update_automation",
52
+ description: "Update rule fields by id. Only send the fields to change.",
53
+ inputSchema: {
54
+ id: z.string().uuid(),
55
+ ...Object.fromEntries(Object.entries(ruleFields).map(([k, v]) => [k, v.optional()])),
56
+ },
57
+ handler: (c, { id, ...body }) => c.call("PATCH", `/automations/${id}`, { body }),
58
+ },
59
+ {
60
+ name: "toggle_automation",
61
+ description: "Turn a rule on or off without editing it.",
62
+ inputSchema: { id: z.string().uuid(), on: z.boolean() },
63
+ handler: (c, { id, on }) => c.call("POST", `/automations/${id}/toggle`, { body: { on } }),
64
+ },
65
+ {
66
+ name: "delete_automation",
67
+ description: "Permanently delete an automation rule.",
68
+ inputSchema: { id: z.string().uuid() },
69
+ annotations: { destructiveHint: true },
70
+ handler: (c, { id }) => c.call("DELETE", `/automations/${id}`),
71
+ },
72
+ {
73
+ name: "preview_automation_conflicts",
74
+ description: "Dry-run conflict check for a prospective rule — returns conflicting existing rules without creating anything. " +
75
+ "Pass exclude_id when checking an edit to an existing rule.",
76
+ inputSchema: {
77
+ trigger: ruleFields.trigger,
78
+ keyword: ruleFields.keyword,
79
+ action: ruleFields.action,
80
+ message: ruleFields.message,
81
+ accounts: ruleFields.accounts,
82
+ post_scope: ruleFields.post_scope,
83
+ reply_also: ruleFields.reply_also,
84
+ exclude_id: z.string().uuid().optional(),
85
+ },
86
+ annotations: { readOnlyHint: true },
87
+ handler: (c, a) => c.call("POST", "/automations/conflicts/preview", { body: a }),
88
+ },
89
+ {
90
+ name: "list_automation_runs",
91
+ description: "Recent executions of a rule (what fired, when, outcome). Cursor with before=RFC3339 timestamp.",
92
+ inputSchema: {
93
+ id: z.string().uuid(),
94
+ limit: z.number().int().min(1).optional(),
95
+ before: z.string().optional().describe("RFC3339 timestamp cursor"),
96
+ },
97
+ annotations: { readOnlyHint: true },
98
+ handler: (c, { id, ...rest }) => c.call("GET", `/automations/${id}/runs`, { query: rest }),
99
+ },
100
+ ];
@@ -0,0 +1,28 @@
1
+ import { z } from "zod";
2
+ export const lookupTools = [
3
+ {
4
+ name: "list_stores",
5
+ description: "List the user's storefronts. Use a store's id for product store_id or automation action=dm_store.",
6
+ inputSchema: {},
7
+ annotations: { readOnlyHint: true },
8
+ handler: (c) => c.call("GET", "/stores"),
9
+ },
10
+ {
11
+ name: "list_instagram_accounts",
12
+ description: "List the user's connected Instagram accounts (handle, ig_user_id, connection health). " +
13
+ "Automations reference accounts by handle.",
14
+ inputSchema: {},
15
+ annotations: { readOnlyHint: true },
16
+ handler: (c) => c.call("GET", "/instagram/accounts"),
17
+ },
18
+ {
19
+ name: "list_instagram_media",
20
+ description: "List recent posts of a connected IG account (media id, caption, permalink). " +
21
+ "Use a media id as accounts[].post_id for post_scope=specific automations.",
22
+ inputSchema: {
23
+ ig_user_id: z.string().describe("The account's ig_user_id from list_instagram_accounts"),
24
+ },
25
+ annotations: { readOnlyHint: true },
26
+ handler: (c, { ig_user_id }) => c.call("GET", `/instagram/accounts/${ig_user_id}/media`),
27
+ },
28
+ ];
@@ -0,0 +1,77 @@
1
+ import { z } from "zod";
2
+ // Mirrors products.LinkInput (api/internal/products/dto.go). Prices are
3
+ // integer minor units (IDR has no cents — 15000 = Rp 15.000).
4
+ const linkInput = {
5
+ id: z.string().uuid().optional().describe("Existing link id — only when replacing links and keeping a row"),
6
+ platform: z.string().describe("Marketplace platform key, e.g. shopee, tokopedia, tiktok_shop, lazada, website"),
7
+ url: z.string().describe("Full product URL on that platform"),
8
+ price: z.number().int().optional().describe("Current price in integer rupiah (no decimals)"),
9
+ original: z.number().int().optional().describe("Pre-discount price in integer rupiah; omit if no discount"),
10
+ label: z.string().optional().describe("Optional badge label shown on the button, e.g. 'Official Store'"),
11
+ };
12
+ const productFields = {
13
+ slug: z.string().describe("URL slug for the public product page (/p/<slug>). Lowercase, hyphenated."),
14
+ name: z.string().max(200).describe("Product display name"),
15
+ desc: z.string().optional().describe("Short description shown on the product page"),
16
+ tag: z.string().optional().describe("Free-form tag/category label"),
17
+ img_preset: z.string().optional().describe("Preset illustration id (alternative to img_url)"),
18
+ img_url: z.string().optional().describe("Product image URL (use the dashboard to upload; pass an existing URL here)"),
19
+ featured: z.boolean().optional(),
20
+ active: z.boolean().optional().describe("Inactive products hide from the public page"),
21
+ mode: z.enum(["platforms", "redirect"]).describe("platforms = landing page listing marketplace buttons; redirect = short link straight to one URL"),
22
+ redirect_url: z.string().optional().describe("Required when mode=redirect: the destination URL"),
23
+ store_id: z.string().uuid().optional().describe("Attach to one of the user's stores (see list_stores)"),
24
+ links: z.array(z.object(linkInput)).optional().describe("Required when mode=platforms: at least one marketplace link"),
25
+ };
26
+ export const productTools = [
27
+ {
28
+ name: "list_products",
29
+ description: "List the user's products with pagination. Filters: q (name search), active, store_id (uuid, or 'none' for products not in any store).",
30
+ inputSchema: {
31
+ q: z.string().optional(),
32
+ active: z.boolean().optional(),
33
+ store_id: z.string().optional(),
34
+ page: z.number().int().min(1).optional(),
35
+ limit: z.number().int().min(1).optional(),
36
+ },
37
+ annotations: { readOnlyHint: true },
38
+ handler: (c, a) => c.call("GET", "/products", { query: a }),
39
+ },
40
+ {
41
+ name: "get_product",
42
+ description: "Fetch one product by id, including its links and short-link click URLs.",
43
+ inputSchema: { id: z.string().uuid() },
44
+ annotations: { readOnlyHint: true },
45
+ handler: (c, { id }) => c.call("GET", `/products/${id}`),
46
+ },
47
+ {
48
+ name: "create_product",
49
+ description: "Create a product. mode=platforms needs links[] (marketplace buttons); mode=redirect needs redirect_url. " +
50
+ "The API auto-creates short links and returns their click URLs.",
51
+ inputSchema: productFields,
52
+ handler: (c, a) => c.call("POST", "/products", { body: a }),
53
+ },
54
+ {
55
+ name: "update_product",
56
+ description: "Update product fields by id. Only send the fields to change. Links are managed via replace_product_links.",
57
+ inputSchema: {
58
+ id: z.string().uuid(),
59
+ ...Object.fromEntries(Object.entries(productFields).map(([k, v]) => [k, v.optional()])),
60
+ },
61
+ handler: (c, { id, ...body }) => c.call("PATCH", `/products/${id}`, { body }),
62
+ },
63
+ {
64
+ name: "delete_product",
65
+ description: "Permanently delete a product and its short links.",
66
+ inputSchema: { id: z.string().uuid() },
67
+ annotations: { destructiveHint: true },
68
+ handler: (c, { id }) => c.call("DELETE", `/products/${id}`),
69
+ },
70
+ {
71
+ name: "replace_product_links",
72
+ description: "Replace ALL marketplace links of a mode=platforms product in one shot. " +
73
+ "Include id on rows to keep (preserves click stats); rows without id are created; omitted rows are deleted.",
74
+ inputSchema: { id: z.string().uuid(), links: z.array(z.object(linkInput)) },
75
+ handler: (c, { id, links }) => c.call("PUT", `/products/${id}/links`, { body: { links } }),
76
+ },
77
+ ];
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@reynandaptr/replyto-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Replyto — manage your products and Instagram automations from any AI client",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/reynandaptr/replyto-mcp.git"
10
+ },
11
+ "homepage": "https://github.com/reynandaptr/replyto-mcp#readme",
12
+ "bugs": { "url": "https://github.com/reynandaptr/replyto-mcp/issues" },
13
+ "bin": { "replyto-mcp": "dist/index.js" },
14
+ "files": ["dist"],
15
+ "engines": { "node": ">=18" },
16
+ "scripts": {
17
+ "prebuild": "node scripts/gen-config.mjs",
18
+ "build": "tsc",
19
+ "prepublishOnly": "npm run build",
20
+ "test": "vitest run"
21
+ },
22
+ "dependencies": {
23
+ "@modelcontextprotocol/sdk": "^1.12.0",
24
+ "zod": "^3.24.0"
25
+ },
26
+ "devDependencies": {
27
+ "@types/node": "^20",
28
+ "typescript": "^5.5.0",
29
+ "vitest": "^2.0.0"
30
+ }
31
+ }