vibie-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,79 @@
1
+ # vibie-mcp
2
+
3
+ MCP server for [Vibie](https://vibie.io) — deploy static folders to permanent `*.vibie.page` URLs from Claude Desktop / Cursor / any MCP-compatible client.
4
+
5
+ ## Install
6
+
7
+ In your MCP client's config, add:
8
+
9
+ ### Claude Desktop
10
+
11
+ `%APPDATA%\Claude\claude_desktop_config.json` (Windows) or `~/Library/Application Support/Claude/claude_desktop_config.json` (macOS):
12
+
13
+ ```json
14
+ {
15
+ "mcpServers": {
16
+ "vibie": {
17
+ "command": "npx",
18
+ "args": ["-y", "vibie-mcp"]
19
+ }
20
+ }
21
+ }
22
+ ```
23
+
24
+ ### Cursor
25
+
26
+ Same pattern — add to your Cursor MCP config.
27
+
28
+ After saving, restart your client.
29
+
30
+ ## First-time auth
31
+
32
+ On first tool call, the server initiates an [OAuth 2.0 Device Authorization Grant](https://datatracker.ietf.org/doc/html/rfc8628). Your AI will receive instructions like:
33
+
34
+ > Please open https://vibie.io/device?code=XXXX-YYYY in a browser, sign in with Google, and click **Authorize**. Then ask me to try again.
35
+
36
+ After authorizing in the browser, ask your AI to retry the same request. The server stores a token in `~/.vibie/credentials.json` (chmod 0600) and reuses it for future calls.
37
+
38
+ You can revoke the token anytime at https://vibie.io/settings/api.
39
+
40
+ ## Tools
41
+
42
+ - **`vibie_create_site`** — Upload a folder and create a new Vibie site. Auto-writes `.vibie/site.json` in the folder so future updates use the same site.
43
+ - **`vibie_update_site`** — Re-deploy to an existing site. Reads slug from `.vibie/site.json` if not specified.
44
+ - **`vibie_list_sites`** — List sites under your account.
45
+ - **`vibie_get_site`** — Metadata for one site by slug.
46
+
47
+ ## How AI typically uses it
48
+
49
+ ```
50
+ You: "Deploy this folder to vibie"
51
+ AI: → vibie_create_site({ folder: "." })
52
+ → Returns: https://my-folder-x7f2.vibie.page
53
+
54
+ You: "Push my changes"
55
+ AI: Detects .vibie/site.json
56
+ → vibie_update_site({ folder: "." })
57
+ → Same URL, new content
58
+ ```
59
+
60
+ ## Folder structure
61
+
62
+ What gets uploaded from a folder:
63
+
64
+ - `index.html` + `style.css` + `js/`, `assets/`, etc — all included
65
+ - Hidden files (`.git`, `.vibie`, `.DS_Store`) — automatically skipped
66
+ - `node_modules/` — skipped
67
+ - Limits: 100 MB per site, 500 files per upload, 25 MB per file (Vibie's server validates)
68
+
69
+ A single HTML file (any name) also works — it gets auto-renamed to `index.html` on upload.
70
+
71
+ ## Env
72
+
73
+ | Variable | Default | Purpose |
74
+ |---|---|---|
75
+ | `VIBIE_API_BASE` | `https://vibie.io` | Override for local dev (`http://localhost:3000`) |
76
+
77
+ ## License
78
+
79
+ MIT
package/dist/api.js ADDED
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Vibie HTTP API 클라이언트 (sites CRUD).
3
+ *
4
+ * 모든 호출에 Authorization: Bearer <token> 헤더. 401 면 ApiAuthError 던짐
5
+ * (handler 가 catch 해서 credentials 폐기 + 재인증 안내).
6
+ */
7
+ import { promises as fs } from "node:fs";
8
+ import { join, relative, sep } from "node:path";
9
+ const DEFAULT_BASE = process.env.VIBIE_API_BASE ?? "https://vibie.io";
10
+ export class ApiAuthError extends Error {
11
+ constructor(message = "Authentication failed (token revoked or invalid).") {
12
+ super(message);
13
+ this.name = "ApiAuthError";
14
+ }
15
+ }
16
+ export class ApiError extends Error {
17
+ status;
18
+ code;
19
+ constructor(status, code, message) {
20
+ super(message);
21
+ this.name = "ApiError";
22
+ this.status = status;
23
+ this.code = code;
24
+ }
25
+ }
26
+ /** /api/sites GET — 본인 사이트 목록 */
27
+ export async function listSites(token) {
28
+ const data = await jsonRequest(token, {
29
+ path: "/api/sites",
30
+ method: "GET"
31
+ });
32
+ return data.sites ?? [];
33
+ }
34
+ /** /api/sites/[slug] — 단일 site 메타. 우리 사용자 본인 / 공개 사이트만 응답. */
35
+ export async function getSite(token, slug) {
36
+ // 우리 API 에 GET /api/sites/[slug] 가 없음 (PATCH/DELETE 만).
37
+ // list 에서 필터.
38
+ const sites = await listSites(token);
39
+ return sites.find((s) => s.slug === slug) ?? null;
40
+ }
41
+ /** /api/sites POST — multipart 로 폴더 전체 업로드, 새 사이트 생성 */
42
+ export async function createSite(token, input) {
43
+ const files = await collectFolderFiles(input.folder);
44
+ const inferredName = input.name ?? folderBaseName(input.folder) ?? "site";
45
+ const fd = new FormData();
46
+ fd.append("name", inferredName);
47
+ fd.append("slug", inferredName);
48
+ fd.append("category", input.category ?? "misc");
49
+ if (input.note)
50
+ fd.append("note", input.note);
51
+ fd.append("isPublic", input.isPublic === false ? "false" : "true");
52
+ fd.append("paths", JSON.stringify(files.map((f) => f.relPath)));
53
+ for (const f of files) {
54
+ fd.append("files", new Blob([new Uint8Array(f.bytes)]), f.relPath);
55
+ }
56
+ const data = await jsonRequest(token, {
57
+ path: "/api/sites",
58
+ method: "POST",
59
+ body: fd
60
+ });
61
+ return data;
62
+ }
63
+ /** /api/sites/[slug]/deployments POST — 기존 site 재배포 */
64
+ export async function updateSite(token, input) {
65
+ const files = await collectFolderFiles(input.folder);
66
+ const fd = new FormData();
67
+ if (input.note)
68
+ fd.append("note", input.note);
69
+ fd.append("paths", JSON.stringify(files.map((f) => f.relPath)));
70
+ for (const f of files) {
71
+ fd.append("files", new Blob([new Uint8Array(f.bytes)]), f.relPath);
72
+ }
73
+ const data = await jsonRequest(token, {
74
+ path: `/api/sites/${encodeURIComponent(input.slug)}/deployments`,
75
+ method: "POST",
76
+ body: fd
77
+ });
78
+ return data;
79
+ }
80
+ // ─── 내부 helpers ──────────────────────────────────────────────────────────
81
+ async function jsonRequest(token, opts) {
82
+ const res = await fetch(`${DEFAULT_BASE}${opts.path}`, {
83
+ method: opts.method,
84
+ headers: { Authorization: `Bearer ${token}` },
85
+ body: opts.body
86
+ });
87
+ if (res.status === 401) {
88
+ throw new ApiAuthError();
89
+ }
90
+ if (!res.ok) {
91
+ let code = "UNKNOWN";
92
+ let message = `Request failed (${res.status})`;
93
+ try {
94
+ const data = (await res.json());
95
+ if (data?.error?.code)
96
+ code = data.error.code;
97
+ if (data?.error?.message)
98
+ message = data.error.message;
99
+ }
100
+ catch {
101
+ // body 가 JSON 아닐 수도
102
+ }
103
+ throw new ApiError(res.status, code, message);
104
+ }
105
+ return (await res.json());
106
+ }
107
+ async function collectFolderFiles(folder) {
108
+ const stat = await fs.stat(folder);
109
+ if (stat.isFile()) {
110
+ // 단일 파일 — 그대로 업로드
111
+ const bytes = await fs.readFile(folder);
112
+ const name = folder.split(sep).pop() ?? "index.html";
113
+ return [{ relPath: name, bytes }];
114
+ }
115
+ if (!stat.isDirectory()) {
116
+ throw new Error(`${folder} is neither a file nor a directory`);
117
+ }
118
+ const out = [];
119
+ async function walk(dir) {
120
+ const entries = await fs.readdir(dir, { withFileTypes: true });
121
+ for (const entry of entries) {
122
+ // hidden 디렉토리/파일 무시 (.git, .vibie, .DS_Store 등)
123
+ if (entry.name.startsWith("."))
124
+ continue;
125
+ if (entry.name === "node_modules")
126
+ continue;
127
+ const full = join(dir, entry.name);
128
+ if (entry.isDirectory()) {
129
+ await walk(full);
130
+ }
131
+ else if (entry.isFile()) {
132
+ const bytes = await fs.readFile(full);
133
+ const rel = relative(folder, full).split(sep).join("/");
134
+ out.push({ relPath: rel, bytes });
135
+ }
136
+ }
137
+ }
138
+ await walk(folder);
139
+ if (out.length === 0) {
140
+ throw new Error(`No files found in ${folder}`);
141
+ }
142
+ return out;
143
+ }
144
+ function folderBaseName(folder) {
145
+ const segs = folder.replace(/[\\/]+$/, "").split(/[\\/]/);
146
+ const last = segs[segs.length - 1];
147
+ if (!last || last === "." || last === "..")
148
+ return undefined;
149
+ return last;
150
+ }
package/dist/auth.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Vibie 인증 — OAuth 2.0 Device Authorization Grant 클라이언트.
3
+ *
4
+ * 흐름:
5
+ * 1. ~/.vibie/credentials.json 에 토큰 있으면 그대로 사용
6
+ * 2. ~/.vibie/pending.json 에 in-flight device_code 있으면 한 번 poll
7
+ * - authorized 면 토큰 저장 + return
8
+ * - pending/slow_down 이면 AuthRequiredError 던짐 (사용자에게 URL 재안내)
9
+ * - expired/denied 면 pending 폐기 후 step 3
10
+ * 3. 새 init 호출 → device_code 받음 → pending 저장 → AuthRequiredError 던짐
11
+ *
12
+ * AuthRequiredError 가 tool handler 에서 catch 돼서 사용자에게 URL/코드 보임.
13
+ * 사용자가 브라우저 authorize 후 다음 tool 호출 시 step 2 가 토큰 회수.
14
+ */
15
+ import { promises as fs } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, dirname } from "node:path";
18
+ const HOME = homedir();
19
+ const VIBIE_DIR = join(HOME, ".vibie");
20
+ const CREDENTIALS_PATH = join(VIBIE_DIR, "credentials.json");
21
+ const PENDING_PATH = join(VIBIE_DIR, "pending.json");
22
+ const DEFAULT_BASE = process.env.VIBIE_API_BASE ?? "https://vibie.io";
23
+ const CLIENT_NAME = "Vibie MCP";
24
+ export class AuthRequiredError extends Error {
25
+ userCode;
26
+ verificationUriComplete;
27
+ constructor(userCode, verificationUriComplete) {
28
+ super(`Authentication required: visit ${verificationUriComplete}`);
29
+ this.name = "AuthRequiredError";
30
+ this.userCode = userCode;
31
+ this.verificationUriComplete = verificationUriComplete;
32
+ }
33
+ }
34
+ /** 토큰 받기. 없으면 device flow 시작 후 AuthRequiredError 던짐. */
35
+ export async function getTokenOrInitiateFlow() {
36
+ // 1) 저장된 credentials
37
+ const cred = await readJson(CREDENTIALS_PATH);
38
+ if (cred?.token) {
39
+ return cred.token;
40
+ }
41
+ // 2) 진행 중인 device flow 가 있으면 한 번 poll
42
+ const pending = await readJson(PENDING_PATH);
43
+ if (pending?.device_code) {
44
+ if (Date.now() > pending.expires_at_ms) {
45
+ // 만료됐으니 폐기 후 새로 init
46
+ await removeFile(PENDING_PATH);
47
+ }
48
+ else {
49
+ const result = await pollDevice(pending.device_code);
50
+ if (result.status === "authorized" && result.access_token) {
51
+ await saveCredentials({
52
+ token: result.access_token,
53
+ saved_at: new Date().toISOString()
54
+ });
55
+ await removeFile(PENDING_PATH);
56
+ return result.access_token;
57
+ }
58
+ if (result.status === "pending" || result.status === "slow_down") {
59
+ throw new AuthRequiredError(pending.user_code, pending.verification_uri_complete);
60
+ }
61
+ // expired/denied — pending 폐기 후 새로 init
62
+ await removeFile(PENDING_PATH);
63
+ }
64
+ }
65
+ // 3) 새 device flow init
66
+ const init = await initDevice();
67
+ await savePending({
68
+ device_code: init.device_code,
69
+ user_code: init.user_code,
70
+ verification_uri_complete: init.verification_uri_complete,
71
+ expires_at_ms: Date.now() + init.expires_in * 1000
72
+ });
73
+ throw new AuthRequiredError(init.user_code, init.verification_uri_complete);
74
+ }
75
+ /** 저장된 토큰 명시적 삭제 — 로그아웃. */
76
+ export async function clearCredentials() {
77
+ await removeFile(CREDENTIALS_PATH);
78
+ await removeFile(PENDING_PATH);
79
+ }
80
+ async function initDevice() {
81
+ const res = await fetch(`${DEFAULT_BASE}/api/auth/device/init`, {
82
+ method: "POST",
83
+ headers: { "content-type": "application/json" },
84
+ body: JSON.stringify({ client_name: CLIENT_NAME })
85
+ });
86
+ if (!res.ok) {
87
+ const text = await res.text().catch(() => "");
88
+ throw new Error(`Vibie /init failed (${res.status}): ${text}`);
89
+ }
90
+ return (await res.json());
91
+ }
92
+ async function pollDevice(deviceCode) {
93
+ const res = await fetch(`${DEFAULT_BASE}/api/auth/device/poll`, {
94
+ method: "POST",
95
+ headers: { "content-type": "application/json" },
96
+ body: JSON.stringify({ device_code: deviceCode })
97
+ });
98
+ if (!res.ok) {
99
+ const text = await res.text().catch(() => "");
100
+ throw new Error(`Vibie /poll failed (${res.status}): ${text}`);
101
+ }
102
+ return (await res.json());
103
+ }
104
+ // ─── 파일 IO helpers ──────────────────────────────────────────────────────
105
+ async function readJson(path) {
106
+ try {
107
+ const raw = await fs.readFile(path, "utf8");
108
+ return JSON.parse(raw);
109
+ }
110
+ catch {
111
+ return null;
112
+ }
113
+ }
114
+ async function ensureDir(path) {
115
+ await fs.mkdir(dirname(path), { recursive: true });
116
+ }
117
+ async function saveCredentials(cred) {
118
+ await ensureDir(CREDENTIALS_PATH);
119
+ await fs.writeFile(CREDENTIALS_PATH, JSON.stringify(cred, null, 2), {
120
+ mode: 0o600
121
+ });
122
+ }
123
+ async function savePending(p) {
124
+ await ensureDir(PENDING_PATH);
125
+ await fs.writeFile(PENDING_PATH, JSON.stringify(p, null, 2), { mode: 0o600 });
126
+ }
127
+ async function removeFile(path) {
128
+ try {
129
+ await fs.unlink(path);
130
+ }
131
+ catch {
132
+ // already missing
133
+ }
134
+ }
package/dist/index.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * vibie-mcp — Vibie 를 위한 MCP server.
4
+ *
5
+ * 사용:
6
+ * npx vibie-mcp
7
+ *
8
+ * Claude Desktop / Cursor config 예시:
9
+ * {
10
+ * "mcpServers": {
11
+ * "vibie": { "command": "npx", "args": ["-y", "vibie-mcp"] }
12
+ * }
13
+ * }
14
+ *
15
+ * 첫 사용 시 사용자에게 https://vibie.io/device 인증 URL 안내 — credentials 는
16
+ * ~/.vibie/credentials.json 에 자동 저장 (chmod 0600).
17
+ *
18
+ * Env (옵션):
19
+ * VIBIE_API_BASE — default https://vibie.io. 로컬 dev 시 http://localhost:3000.
20
+ */
21
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
22
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
23
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
24
+ import { toolDefinitions, handleToolCall } from "./tools.js";
25
+ const server = new Server({
26
+ name: "vibie",
27
+ version: "0.1.0"
28
+ }, {
29
+ capabilities: { tools: {} }
30
+ });
31
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
32
+ tools: toolDefinitions
33
+ }));
34
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
35
+ const name = request.params.name;
36
+ const args = (request.params.arguments ?? {});
37
+ return handleToolCall(name, args);
38
+ });
39
+ const transport = new StdioServerTransport();
40
+ await server.connect(transport);
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `.vibie/site.json` 폴더 마커 — 한 폴더가 어떤 vibie 사이트에 매핑되는지 추적.
3
+ *
4
+ * Vercel CLI 의 `.vercel/project.json` 같은 패턴. update 시 "어떤 사이트?" 물을 필요 X.
5
+ *
6
+ * 파일 위치: {folder}/.vibie/site.json
7
+ * 함께 자동 생성: {folder}/.vibie/.gitignore — 폴더 전체 git ignore (사용자 site 와 분리)
8
+ */
9
+ import { promises as fs } from "node:fs";
10
+ import { join } from "node:path";
11
+ const MARKER_DIRNAME = ".vibie";
12
+ const MARKER_FILENAME = "site.json";
13
+ const GITIGNORE_CONTENT = "# vibie cli local state\n*\n!.gitignore\n";
14
+ export async function readMarker(folder) {
15
+ try {
16
+ const raw = await fs.readFile(join(folder, MARKER_DIRNAME, MARKER_FILENAME), "utf8");
17
+ return JSON.parse(raw);
18
+ }
19
+ catch {
20
+ return null;
21
+ }
22
+ }
23
+ export async function writeMarker(folder, marker) {
24
+ const dir = join(folder, MARKER_DIRNAME);
25
+ await fs.mkdir(dir, { recursive: true });
26
+ await fs.writeFile(join(dir, MARKER_FILENAME), JSON.stringify(marker, null, 2), "utf8");
27
+ // .gitignore 자동 — 사용자가 vibie 마커를 자기 git 에 안 넣게
28
+ const gitignorePath = join(dir, ".gitignore");
29
+ try {
30
+ await fs.access(gitignorePath);
31
+ // 이미 있으면 손대지 않음
32
+ }
33
+ catch {
34
+ await fs.writeFile(gitignorePath, GITIGNORE_CONTENT, "utf8");
35
+ }
36
+ }
37
+ export async function clearMarker(folder) {
38
+ try {
39
+ await fs.unlink(join(folder, MARKER_DIRNAME, MARKER_FILENAME));
40
+ }
41
+ catch {
42
+ // 없으면 그만
43
+ }
44
+ }
package/dist/tools.js ADDED
@@ -0,0 +1,252 @@
1
+ /**
2
+ * 4개 MCP tool 정의 + handler.
3
+ *
4
+ * - vibie_create_site: 새 사이트 생성 + .vibie/site.json 자동 생성
5
+ * - vibie_update_site: 같은 폴더 재배포. slug 안 주면 .vibie/site.json 에서 읽음
6
+ * - vibie_list_sites: 본인 사이트 목록
7
+ * - vibie_get_site: 특정 slug 메타
8
+ *
9
+ * 인증 친화 — 미인증이면 AuthRequiredError catch 후 사용자에게 URL/코드 안내.
10
+ */
11
+ import { resolve } from "node:path";
12
+ import { AuthRequiredError, getTokenOrInitiateFlow, clearCredentials } from "./auth.js";
13
+ import { ApiAuthError, ApiError, createSite, getSite, listSites, updateSite } from "./api.js";
14
+ import { readMarker, writeMarker } from "./site-marker.js";
15
+ // ─── tool 정의들 (LLM 한테 보이는 description 이 가장 중요) ──────────────────
16
+ export const toolDefinitions = [
17
+ {
18
+ name: "vibie_create_site",
19
+ description: "Create a NEW Vibie site by uploading a local folder. Use this when the user wants to publish a brand new site, or when no .vibie/site.json marker exists in the folder. Vibie hosts static HTML/CSS/JS at a permanent URL like my-site-a3f9.vibie.page. Auto-creates a .vibie/site.json marker in the folder for future updates.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ folder: {
24
+ type: "string",
25
+ description: "Absolute or relative path to the folder containing index.html (or a single HTML file). Required."
26
+ },
27
+ name: {
28
+ type: "string",
29
+ description: "Display name. Defaults to folder name."
30
+ },
31
+ category: {
32
+ type: "string",
33
+ description: "Category id (portfolio / wedding / landing / event / game / photo / linkbio / misc). Defaults to misc."
34
+ },
35
+ is_public: {
36
+ type: "boolean",
37
+ description: "Whether the site appears in the public gallery. Defaults to true."
38
+ },
39
+ note: {
40
+ type: "string",
41
+ description: "Optional deployment note (changelog-style)."
42
+ }
43
+ },
44
+ required: ["folder"],
45
+ additionalProperties: false
46
+ }
47
+ },
48
+ {
49
+ name: "vibie_update_site",
50
+ description: "Update an existing Vibie site with new content from a local folder. Use this when the user wants to re-deploy or push changes to an already-published site. If .vibie/site.json marker exists in the folder, the slug is read automatically; otherwise pass slug explicitly. Same URL, new content.",
51
+ inputSchema: {
52
+ type: "object",
53
+ properties: {
54
+ folder: {
55
+ type: "string",
56
+ description: "Folder to upload. Required."
57
+ },
58
+ slug: {
59
+ type: "string",
60
+ description: "Site slug to update (e.g. my-site-a3f9). If omitted, read from .vibie/site.json in folder."
61
+ },
62
+ note: {
63
+ type: "string",
64
+ description: "Optional deployment note (changelog-style)."
65
+ }
66
+ },
67
+ required: ["folder"],
68
+ additionalProperties: false
69
+ }
70
+ },
71
+ {
72
+ name: "vibie_list_sites",
73
+ description: "List all sites owned by the current Vibie user. Returns slug, display name, category, URL, visibility, and last update time. Use this to discover what sites the user already has, e.g. before update.",
74
+ inputSchema: {
75
+ type: "object",
76
+ properties: {},
77
+ additionalProperties: false
78
+ }
79
+ },
80
+ {
81
+ name: "vibie_get_site",
82
+ description: "Get metadata for a single Vibie site by slug. Returns name, category, URL, visibility, like count, timestamps.",
83
+ inputSchema: {
84
+ type: "object",
85
+ properties: {
86
+ slug: {
87
+ type: "string",
88
+ description: "Site slug, e.g. my-site-a3f9"
89
+ }
90
+ },
91
+ required: ["slug"],
92
+ additionalProperties: false
93
+ }
94
+ }
95
+ ];
96
+ // ─── handler dispatcher ────────────────────────────────────────────────────
97
+ export async function handleToolCall(name, args) {
98
+ try {
99
+ const token = await getTokenOrInitiateFlow();
100
+ switch (name) {
101
+ case "vibie_create_site":
102
+ return await handleCreate(token, args);
103
+ case "vibie_update_site":
104
+ return await handleUpdate(token, args);
105
+ case "vibie_list_sites":
106
+ return await handleList(token);
107
+ case "vibie_get_site":
108
+ return await handleGet(token, args);
109
+ default:
110
+ return errorResult(`Unknown tool: ${name}`);
111
+ }
112
+ }
113
+ catch (err) {
114
+ if (err instanceof AuthRequiredError) {
115
+ return {
116
+ content: [
117
+ {
118
+ type: "text",
119
+ text: "Vibie 인증이 필요합니다. 사용자에게 다음 단계를 안내해 주세요:\n\n" +
120
+ `1. 브라우저로 이 URL 을 여세요: ${err.verificationUriComplete}\n` +
121
+ `2. Google 로 로그인하고 코드 ${err.userCode} 를 확인한 뒤 [권한 부여] 버튼을 누르세요.\n` +
122
+ `3. 완료되면 다시 '배포해줘' 또는 원래 요청을 해주세요. 같은 도구 호출이 다시 들어오면 토큰을 가져와서 진행합니다.\n\n` +
123
+ `(코드는 10분 안에 사용해야 합니다. 만료되면 새 코드가 발급됩니다.)`
124
+ }
125
+ ]
126
+ };
127
+ }
128
+ if (err instanceof ApiAuthError) {
129
+ // 토큰이 revoke 됐거나 invalid — credentials 폐기 + 재인증 안내
130
+ await clearCredentials();
131
+ return {
132
+ content: [
133
+ {
134
+ type: "text",
135
+ text: "Vibie 토큰이 더 이상 유효하지 않습니다 (revoke 됐거나 만료). " +
136
+ "credentials 를 비웠습니다. 같은 도구를 다시 호출해주시면 새 인증 흐름을 시작합니다."
137
+ }
138
+ ],
139
+ isError: true
140
+ };
141
+ }
142
+ if (err instanceof ApiError) {
143
+ return errorResult(`Vibie API ${err.status} ${err.code}: ${err.message}`);
144
+ }
145
+ return errorResult(err instanceof Error ? err.message : "Unknown error");
146
+ }
147
+ }
148
+ // ─── 개별 handler ──────────────────────────────────────────────────────────
149
+ async function handleCreate(token, args) {
150
+ const folder = requireStr(args, "folder");
151
+ const absFolder = resolve(folder);
152
+ const result = await createSite(token, {
153
+ folder: absFolder,
154
+ name: optStr(args, "name"),
155
+ category: optStr(args, "category"),
156
+ isPublic: optBool(args, "is_public"),
157
+ note: optStr(args, "note")
158
+ });
159
+ // .vibie/site.json 자동 생성 — 다음 배포 시 update_site 가 slug 자동 인지
160
+ await writeMarker(absFolder, {
161
+ slug: result.site.slug,
162
+ siteId: result.site.id,
163
+ url: result.url,
164
+ lastDeployed: new Date().toISOString()
165
+ });
166
+ return successResult(`✓ 새 Vibie 사이트가 배포됐습니다.\n` +
167
+ ` URL: ${result.url}\n` +
168
+ ` Slug: ${result.site.slug}\n` +
169
+ ` Name: ${result.site.display_name}\n` +
170
+ ` Public: ${result.site.is_public}\n\n` +
171
+ `다음에 같은 폴더에서 vibie_update_site 를 호출하면 같은 사이트가 갱신됩니다 (.vibie/site.json 자동 인식).`);
172
+ }
173
+ async function handleUpdate(token, args) {
174
+ const folder = requireStr(args, "folder");
175
+ const absFolder = resolve(folder);
176
+ let slug = optStr(args, "slug");
177
+ if (!slug) {
178
+ const marker = await readMarker(absFolder);
179
+ if (!marker) {
180
+ return errorResult(`slug 를 알 수 없습니다. ${absFolder}/.vibie/site.json 마커가 없고 인자로도 slug 가 전달되지 않았습니다.\n` +
181
+ `옵션:\n` +
182
+ `1. 새 사이트로 만들려면 vibie_create_site 호출\n` +
183
+ `2. 기존 사이트로 업데이트하려면 slug 인자를 명시 (예: my-site-a3f9). vibie_list_sites 로 본인 사이트 목록 확인 가능.`);
184
+ }
185
+ slug = marker.slug;
186
+ }
187
+ const result = await updateSite(token, {
188
+ folder: absFolder,
189
+ slug,
190
+ note: optStr(args, "note")
191
+ });
192
+ // marker 의 lastDeployed 갱신
193
+ const marker = await readMarker(absFolder);
194
+ if (marker) {
195
+ await writeMarker(absFolder, {
196
+ ...marker,
197
+ lastDeployed: new Date().toISOString()
198
+ });
199
+ }
200
+ return successResult(`✓ Vibie 사이트가 업데이트됐습니다.\n` +
201
+ ` URL: ${result.url}\n` +
202
+ ` Slug: ${slug}\n` +
203
+ ` Deployment: ${result.deployment.id}\n`);
204
+ }
205
+ async function handleList(token) {
206
+ const sites = await listSites(token);
207
+ if (sites.length === 0) {
208
+ return successResult("본인 명의로 배포된 사이트가 없습니다. vibie_create_site 로 첫 사이트를 만들어 보세요.");
209
+ }
210
+ const lines = sites.map((s) => {
211
+ const flag = s.is_blocked ? " [차단]" : s.is_public ? "" : " [비공개]";
212
+ const tpl = s.is_template ? " [TEMPLATE]" : "";
213
+ return `· ${s.slug}.vibie.page — ${s.display_name} (${s.category})${flag}${tpl}, ♥${s.like_count}, updated ${s.updated_at}`;
214
+ });
215
+ return successResult(`본인 Vibie 사이트 (${sites.length}개):\n${lines.join("\n")}`);
216
+ }
217
+ async function handleGet(token, args) {
218
+ const slug = requireStr(args, "slug");
219
+ const site = await getSite(token, slug);
220
+ if (!site) {
221
+ return errorResult(`사이트 '${slug}' 를 본인 계정에서 찾을 수 없습니다.`);
222
+ }
223
+ return successResult(`${site.display_name} (${site.slug}.vibie.page)\n` +
224
+ ` Category: ${site.category}\n` +
225
+ ` Public: ${site.is_public}\n` +
226
+ ` Template: ${site.is_template}\n` +
227
+ ` Likes: ${site.like_count}\n` +
228
+ ` Created: ${site.created_at}\n` +
229
+ ` Updated: ${site.updated_at}`);
230
+ }
231
+ // ─── small helpers ────────────────────────────────────────────────────────
232
+ function requireStr(args, key) {
233
+ const v = args[key];
234
+ if (typeof v !== "string" || v.length === 0) {
235
+ throw new Error(`Argument '${key}' is required and must be a non-empty string.`);
236
+ }
237
+ return v;
238
+ }
239
+ function optStr(args, key) {
240
+ const v = args[key];
241
+ return typeof v === "string" && v.length > 0 ? v : undefined;
242
+ }
243
+ function optBool(args, key) {
244
+ const v = args[key];
245
+ return typeof v === "boolean" ? v : undefined;
246
+ }
247
+ function successResult(text) {
248
+ return { content: [{ type: "text", text }] };
249
+ }
250
+ function errorResult(text) {
251
+ return { content: [{ type: "text", text }], isError: true };
252
+ }
package/package.json ADDED
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "vibie-mcp",
3
+ "version": "0.1.0",
4
+ "description": "Vibie MCP server — deploy folders to vibie.page from Claude Desktop / Cursor / any MCP client.",
5
+ "license": "MIT",
6
+ "homepage": "https://vibie.io",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/shdomi8599/vibie.git",
10
+ "directory": "packages/vibie-mcp"
11
+ },
12
+ "keywords": [
13
+ "vibie",
14
+ "mcp",
15
+ "model-context-protocol",
16
+ "claude",
17
+ "cursor",
18
+ "vibe-coding",
19
+ "static-hosting",
20
+ "deploy"
21
+ ],
22
+ "type": "module",
23
+ "main": "dist/index.js",
24
+ "bin": {
25
+ "vibie-mcp": "dist/index.js"
26
+ },
27
+ "files": [
28
+ "dist",
29
+ "README.md"
30
+ ],
31
+ "engines": {
32
+ "node": ">=18"
33
+ },
34
+ "scripts": {
35
+ "build": "tsc",
36
+ "prepare": "npm run build",
37
+ "start": "node dist/index.js",
38
+ "dev": "tsc --watch"
39
+ },
40
+ "dependencies": {
41
+ "@modelcontextprotocol/sdk": "^1.0.0"
42
+ },
43
+ "devDependencies": {
44
+ "@types/node": "^22.0.0",
45
+ "typescript": "^5.5.0"
46
+ }
47
+ }