plyo-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.
Files changed (2) hide show
  1. package/dist/index.js +218 -0
  2. package/package.json +43 -0
package/dist/index.js ADDED
@@ -0,0 +1,218 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Plyo MCP server — the flagship agent integration.
4
+ *
5
+ * Setup (Claude Code): claude mcp add plyo -e PLYO_TOKEN=plyo_... -- npx plyo-mcp
6
+ * Then just say "save my work" or "publish this".
7
+ *
8
+ * Deliberately a thin client: real state lives in the Plyo API and in the
9
+ * project's real git repo. Every tool returns text an agent can act on, and
10
+ * errors pass through the API's {code, message, hint} shape.
11
+ */
12
+ import { execFile } from "node:child_process";
13
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { promisify } from "node:util";
16
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
17
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
18
+ import { z } from "zod";
19
+ const run = promisify(execFile);
20
+ const API_URL = process.env.PLYO_API_URL ?? "https://api.plyo.dev";
21
+ const TOKEN = process.env.PLYO_TOKEN ?? "";
22
+ const REMOTE_NAME = "plyo";
23
+ async function api(path, init = {}) {
24
+ const res = await fetch(`${API_URL}${path}`, {
25
+ ...init,
26
+ headers: {
27
+ "content-type": "application/json",
28
+ authorization: `Bearer ${TOKEN}`,
29
+ ...(init.headers ?? {}),
30
+ },
31
+ });
32
+ const body = (await res.json().catch(() => ({})));
33
+ if (!res.ok) {
34
+ throw new Error(`${body.message ?? `API error ${res.status}`}${body.hint ? `\nHint: ${body.hint}` : ""}`);
35
+ }
36
+ return body;
37
+ }
38
+ async function git(args, cwd = process.cwd()) {
39
+ const { stdout } = await run("git", args, { cwd });
40
+ return stdout.trim();
41
+ }
42
+ async function readLink(cwd = process.cwd()) {
43
+ try {
44
+ return JSON.parse(await readFile(join(cwd, ".plyo", "project.json"), "utf8"));
45
+ }
46
+ catch {
47
+ return null;
48
+ }
49
+ }
50
+ async function requireLink() {
51
+ const link = await readLink();
52
+ if (!link) {
53
+ throw new Error("This folder isn't linked to a Plyo project yet. Use link_project (existing project) or create_project (new one) first.");
54
+ }
55
+ return link;
56
+ }
57
+ function remoteUrl(slug) {
58
+ const host = new URL(API_URL).host;
59
+ const proto = API_URL.startsWith("http://") ? "http" : "https";
60
+ return `${proto}://ai:${TOKEN}@${host}/git/${slug}.git`;
61
+ }
62
+ async function ensureRemote(slug) {
63
+ try {
64
+ await git(["rev-parse", "--git-dir"]);
65
+ }
66
+ catch {
67
+ await git(["init", "-b", "main"]);
68
+ }
69
+ const url = remoteUrl(slug);
70
+ try {
71
+ await git(["remote", "set-url", REMOTE_NAME, url]);
72
+ }
73
+ catch {
74
+ await git(["remote", "add", REMOTE_NAME, url]);
75
+ }
76
+ }
77
+ async function writeLink(link) {
78
+ await mkdir(join(process.cwd(), ".plyo"), { recursive: true });
79
+ await writeFile(join(process.cwd(), ".plyo", "project.json"), JSON.stringify(link, null, 2));
80
+ // Keep the credentialed remote out of the project's own history.
81
+ try {
82
+ const ignorePath = join(process.cwd(), ".gitignore");
83
+ const current = await readFile(ignorePath, "utf8").catch(() => "");
84
+ if (!current.includes(".plyo/")) {
85
+ await writeFile(ignorePath, `${current.trimEnd()}\n.plyo/\n`.trimStart());
86
+ }
87
+ }
88
+ catch {
89
+ /* best effort */
90
+ }
91
+ }
92
+ /**
93
+ * Push to a branch, handling the first-push case: a brand-new project's
94
+ * remote holds only the empty "Project created" commit, which an existing
95
+ * local history can't fast-forward onto. That one case is safe to replace.
96
+ */
97
+ async function pushBranch(branch) {
98
+ try {
99
+ await git(["push", REMOTE_NAME, `HEAD:${branch}`]);
100
+ return;
101
+ }
102
+ catch (err) {
103
+ if (!/fetch first|non-fast-forward|rejected/i.test(String(err)))
104
+ throw err;
105
+ await git(["fetch", REMOTE_NAME, branch]);
106
+ const commitCount = await git(["rev-list", "--count", "FETCH_HEAD"]);
107
+ const tree = await git(["ls-tree", "FETCH_HEAD"]);
108
+ if (commitCount === "1" && tree === "") {
109
+ await git(["push", "--force", REMOTE_NAME, `HEAD:${branch}`]);
110
+ return;
111
+ }
112
+ throw new Error(`The project already has saved work that this folder doesn't have. Bring it in first with: git pull ${REMOTE_NAME} ${branch} --rebase — then save again. (Never force-push; the user's timeline lives there.)`);
113
+ }
114
+ }
115
+ function text(s) {
116
+ return { content: [{ type: "text", text: s }] };
117
+ }
118
+ const server = new McpServer({ name: "plyo", version: "0.1.0" });
119
+ server.tool("list_projects", "List the user's Plyo projects (name, slug, live URL).", {}, async () => {
120
+ const data = await api("/v1/projects");
121
+ if (!data.projects.length)
122
+ return text("No projects yet. create_project starts one.");
123
+ return text(data.projects.map((p) => `${p.name} (slug: ${p.slug}) — ${p.liveUrl}`).join("\n"));
124
+ });
125
+ server.tool("create_project", "Create a new Plyo project and link the current folder to it. Existing files stay untouched; use save_checkpoint afterwards to store them.", { name: z.string().describe("Human-friendly project name, e.g. 'Sunrise Yoga'") }, async ({ name }) => {
126
+ const project = await api("/v1/projects", { method: "POST", body: JSON.stringify({ name }) });
127
+ await ensureRemote(project.slug);
128
+ await writeLink({ projectId: project.id, slug: project.slug });
129
+ return text(`Project "${name}" created and linked to this folder.\nSlug: ${project.slug}\nWill publish at: ${project.liveUrl}\nNext: save_checkpoint to store the current files.`);
130
+ });
131
+ server.tool("link_project", "Link the current folder to an existing Plyo project by slug.", { slug: z.string().describe("The project slug, from list_projects") }, async ({ slug }) => {
132
+ const project = await api(`/v1/projects/${slug}`);
133
+ await ensureRemote(project.slug);
134
+ await writeLink({ projectId: project.id, slug: project.slug });
135
+ return text(`Linked this folder to "${slug}". save_checkpoint now saves to its timeline.`);
136
+ });
137
+ server.tool("save_checkpoint", "Save the current state of the project as a checkpoint on the user's Plyo timeline. Call this after completing a piece of work.", {
138
+ note: z
139
+ .string()
140
+ .describe("One plain-English sentence describing what changed, written for a non-technical user, e.g. 'Added a pricing page with three plans'"),
141
+ }, async ({ note }) => {
142
+ const link = await requireLink();
143
+ await ensureRemote(link.slug);
144
+ await git(["add", "-A"]);
145
+ try {
146
+ await git(["commit", "-m", note]);
147
+ }
148
+ catch (err) {
149
+ const msg = String(err);
150
+ if (!/nothing to commit/i.test(msg))
151
+ throw err;
152
+ }
153
+ // Save into the current draft when one is checked out, else to live.
154
+ const currentBranch = await git(["rev-parse", "--abbrev-ref", "HEAD"]).catch(() => "main");
155
+ const target = currentBranch.startsWith("draft/") ? currentBranch : "live";
156
+ await pushBranch(target);
157
+ return text(`Checkpoint saved: "${note}". It's on the user's timeline and can be restored anytime.`);
158
+ });
159
+ server.tool("start_draft", "Start a Draft — a safe copy of the project to try changes without touching the live version. Switches this folder to the draft.", { name: z.string().describe("What this draft tries, e.g. 'Trying a new pricing page'") }, async ({ name }) => {
160
+ const link = await requireLink();
161
+ const data = await api(`/v1/projects/${link.slug}/drafts`, { method: "POST", body: JSON.stringify({ name }) });
162
+ const branch = data.hint.match(/"([^"]+)"/)?.[1];
163
+ if (branch) {
164
+ await git(["fetch", REMOTE_NAME, branch]);
165
+ await git(["checkout", "-B", branch, `${REMOTE_NAME}/${branch}`]);
166
+ }
167
+ return text(`Draft "${name}" started; this folder now works in the draft. save_checkpoint saves into it. Preview: ${data.draft.previewUrl}. Apply it with the dashboard or POST /v1/drafts/${data.draft.id}/apply.`);
168
+ });
169
+ server.tool("publish", "Publish the project's latest checkpoint to its live URL. Waits for the result; on failure, returns a plain-English cause plus a fix you can act on.", {}, async () => {
170
+ const link = await requireLink();
171
+ const pub = await api(`/v1/projects/${link.slug}/publish`, { method: "POST", body: "{}" });
172
+ for (let i = 0; i < 120; i++) {
173
+ await new Promise((r) => setTimeout(r, 2000));
174
+ const build = await api(`/v1/builds/${pub.build.id}`);
175
+ if (build.status === "ready") {
176
+ return text(`Published. The site is live at ${pub.liveUrl}`);
177
+ }
178
+ if (build.status === "failed" || build.status === "needs_server") {
179
+ return text(`Publish failed: ${build.error_plain}\n\nFix it like this:\n${build.error_fix_prompt}`);
180
+ }
181
+ }
182
+ return text(`The build is taking unusually long. Check later with get_build_error.`);
183
+ });
184
+ server.tool("get_build_error", "Get the most recent build's status, and if it failed, the plain-English error and the fix guidance.", {}, async () => {
185
+ const link = await requireLink();
186
+ const timeline = await api(`/v1/projects/${link.slug}/checkpoints`);
187
+ if (!timeline.checkpoints.length)
188
+ return text("No checkpoints yet, so no builds either.");
189
+ // The events feed carries build outcomes; simplest reliable source is the
190
+ // project's latest publish attempt via /v1/projects/:slug.
191
+ const project = await api(`/v1/projects/${link.slug}`);
192
+ return text(project.isLive
193
+ ? `The site is live at ${project.liveUrl}. If a later publish failed, re-run publish to see the error.`
194
+ : "The project isn't live yet. Run publish to build it — any failure comes back with a plain-English cause and a fix.");
195
+ });
196
+ server.tool("get_timeline", "Show the project's recent checkpoints (the timeline) with what changed in each.", {}, async () => {
197
+ const link = await requireLink();
198
+ const data = await api(`/v1/projects/${link.slug}/checkpoints`);
199
+ if (!data.checkpoints.length)
200
+ return text("No checkpoints yet. save_checkpoint creates the first one.");
201
+ return text(data.checkpoints
202
+ .slice(0, 15)
203
+ .map((c) => `${c.createdAt} — ${c.note} (${c.source}) [${c.id}]`)
204
+ .join("\n"));
205
+ });
206
+ server.tool("restore", "Restore the project to an earlier checkpoint. Never destructive: a new checkpoint is created and everything stays on the timeline.", { checkpointId: z.string().describe("Checkpoint id from get_timeline") }, async ({ checkpointId }) => {
207
+ const link = await requireLink();
208
+ const data = await api(`/v1/projects/${link.slug}/restore`, { method: "POST", body: JSON.stringify({ checkpointId }) });
209
+ await git(["fetch", REMOTE_NAME, "live"]);
210
+ await git(["checkout", "-B", "main", `${REMOTE_NAME}/live`]).catch(() => "");
211
+ return text(`${data.checkpoint.note}. Nothing was deleted — the timeline keeps every version.`);
212
+ });
213
+ if (!TOKEN) {
214
+ console.error("PLYO_TOKEN is not set. Create an AI key in the dashboard (Settings → AI keys) and set PLYO_TOKEN.");
215
+ process.exit(1);
216
+ }
217
+ const transport = new StdioServerTransport();
218
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "plyo-mcp",
3
+ "version": "0.1.0",
4
+ "description": "MCP server for Plyo — Undo and Publish for everything your AI builds. Save checkpoints, publish, restore.",
5
+ "type": "module",
6
+ "bin": {
7
+ "plyo-mcp": "./dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc -p tsconfig.build.json",
14
+ "prepublishOnly": "tsc -p tsconfig.build.json",
15
+ "typecheck": "tsc --noEmit"
16
+ },
17
+ "keywords": [
18
+ "mcp",
19
+ "modelcontextprotocol",
20
+ "plyo",
21
+ "claude",
22
+ "deploy",
23
+ "version-control"
24
+ ],
25
+ "homepage": "https://plyo.dev",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "https://github.com/plyo/plyo"
29
+ },
30
+ "license": "MIT",
31
+ "engines": {
32
+ "node": ">=20"
33
+ },
34
+ "dependencies": {
35
+ "@modelcontextprotocol/sdk": "^1.12.0",
36
+ "zod": "^3.24.0"
37
+ },
38
+ "devDependencies": {
39
+ "@types/node": "^22.10.0",
40
+ "tsx": "^4.19.2",
41
+ "typescript": "^5.9.2"
42
+ }
43
+ }