@vprop/mcp 1.0.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,379 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { readFileSync } from "node:fs";
5
+ import { extname } from "node:path";
6
+ import { randomUUID } from "node:crypto";
7
+ import { z } from "zod";
8
+ const BASE_URL = process.env.VPROP_BASE_URL ?? "https://vprop.ai/api/v1";
9
+ function getApiKey() {
10
+ const key = process.env.VPROP_API_KEY;
11
+ if (!key)
12
+ throw new Error("VPROP_API_KEY environment variable is required");
13
+ return key;
14
+ }
15
+ async function vpropFetch(path, options = {}) {
16
+ const { headers: extraHeaders, ...rest } = options;
17
+ const response = await fetch(`${BASE_URL}${path}`, {
18
+ ...rest,
19
+ headers: {
20
+ Authorization: `Bearer ${getApiKey()}`,
21
+ "Content-Type": "application/json",
22
+ ...extraHeaders,
23
+ },
24
+ });
25
+ if (response.status === 204)
26
+ return null;
27
+ const body = await response.json().catch(() => null);
28
+ if (!response.ok) {
29
+ const msg = body?.error?.message ?? `HTTP ${response.status}`;
30
+ throw new Error(msg);
31
+ }
32
+ return body;
33
+ }
34
+ function toText(data) {
35
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
36
+ }
37
+ const CONTENT_TYPES = {
38
+ ".jpg": "image/jpeg",
39
+ ".jpeg": "image/jpeg",
40
+ ".png": "image/png",
41
+ ".webp": "image/webp",
42
+ };
43
+ async function uploadBuffer(uploadUrl, data, contentType) {
44
+ const res = await fetch(uploadUrl, {
45
+ method: "PUT",
46
+ headers: { "Content-Type": contentType },
47
+ body: data,
48
+ });
49
+ if (!res.ok)
50
+ throw new Error(`S3 PUT failed: HTTP ${res.status}`);
51
+ }
52
+ // ────────────────────────────────────────────────────────────────────────────
53
+ // Server
54
+ // ────────────────────────────────────────────────────────────────────────────
55
+ const server = new McpServer({
56
+ name: "vprop",
57
+ version: "1.0.0",
58
+ description: "vProp Public API — create real-estate video projects from listings and agents. " +
59
+ "Typical flow: create_listing → upload_listing_image (10+ photos) → create_agent → " +
60
+ "upload_agent_photo → create_project → poll get_project_status → get_project (for video_url).",
61
+ });
62
+ // ── Voices ───────────────────────────────────────────────────────────────────
63
+ server.registerTool("list_voices", {
64
+ description: "List all available narration voices. Use the returned `id` as `voice_id` when creating agents or projects.",
65
+ }, async () => toText(await vpropFetch("/voices")));
66
+ // ── BGMs ─────────────────────────────────────────────────────────────────────
67
+ server.registerTool("list_bgms", {
68
+ description: "List all available background music tracks. Use the returned `id` as `bgm_id` when creating projects.",
69
+ }, async () => toText(await vpropFetch("/bgms")));
70
+ // ── Listings ─────────────────────────────────────────────────────────────────
71
+ server.registerTool("create_listing", {
72
+ description: "Create a new property listing. Returns a listing ID (`lst_…`) used for image uploads and project creation.",
73
+ inputSchema: {
74
+ address: z.string().describe("Full US property address, e.g. '123 Main St, San Francisco, CA 94105' (required)"),
75
+ bedrooms: z.number().optional().describe("Number of bedrooms"),
76
+ bathrooms: z.number().optional().describe("Number of bathrooms"),
77
+ square_feet: z.number().optional().describe("Property size in square feet"),
78
+ price: z.number().optional().describe("Listing price in USD"),
79
+ year_built: z.number().optional().describe("Year the property was built"),
80
+ metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary key-value metadata"),
81
+ },
82
+ }, async ({ address, bedrooms, bathrooms, square_feet, price, year_built, metadata }) => {
83
+ const attributes = bedrooms !== undefined || bathrooms !== undefined || square_feet !== undefined || price !== undefined || year_built !== undefined
84
+ ? { bedrooms, bathrooms, square_feet, price, year_built }
85
+ : undefined;
86
+ return toText(await vpropFetch("/listings", {
87
+ method: "POST",
88
+ body: JSON.stringify({ address, attributes, metadata }),
89
+ }));
90
+ });
91
+ server.registerTool("get_listing", {
92
+ description: "Get full details of a listing by its ID.",
93
+ inputSchema: {
94
+ id: z.string().describe("Listing ID, e.g. 'lst_AbCdEfGhIjKl'"),
95
+ },
96
+ }, async ({ id }) => toText(await vpropFetch(`/listings/${id}`)));
97
+ server.registerTool("list_listings", {
98
+ description: "List all listings with cursor-based pagination.",
99
+ inputSchema: {
100
+ limit: z.number().int().min(1).max(100).optional().describe("Results per page (1–100, default 20)"),
101
+ cursor: z.string().optional().describe("Pagination cursor from the previous response's `next_cursor`"),
102
+ },
103
+ }, async ({ limit, cursor }) => {
104
+ const params = new URLSearchParams();
105
+ if (limit !== undefined)
106
+ params.set("limit", String(limit));
107
+ if (cursor)
108
+ params.set("cursor", cursor);
109
+ const qs = params.size ? `?${params}` : "";
110
+ return toText(await vpropFetch(`/listings${qs}`));
111
+ });
112
+ server.registerTool("update_listing", {
113
+ description: "Update an existing listing's address, attributes, or metadata.",
114
+ inputSchema: {
115
+ id: z.string().describe("Listing ID to update"),
116
+ address: z.string().optional().describe("New property address"),
117
+ bedrooms: z.number().optional(),
118
+ bathrooms: z.number().optional(),
119
+ square_feet: z.number().optional(),
120
+ price: z.number().optional(),
121
+ year_built: z.number().optional(),
122
+ metadata: z.record(z.string(), z.unknown()).optional(),
123
+ },
124
+ }, async ({ id, address, bedrooms, bathrooms, square_feet, price, year_built, metadata }) => {
125
+ const attributes = bedrooms !== undefined || bathrooms !== undefined || square_feet !== undefined || price !== undefined || year_built !== undefined
126
+ ? { bedrooms, bathrooms, square_feet, price, year_built }
127
+ : undefined;
128
+ return toText(await vpropFetch(`/listings/${id}`, {
129
+ method: "PATCH",
130
+ body: JSON.stringify({ address, attributes, metadata }),
131
+ }));
132
+ });
133
+ server.registerTool("delete_listing", {
134
+ description: "Permanently delete a listing and its images.",
135
+ inputSchema: {
136
+ id: z.string().describe("Listing ID to delete"),
137
+ },
138
+ }, async ({ id }) => {
139
+ await vpropFetch(`/listings/${id}`, { method: "DELETE" });
140
+ return toText({ deleted: true, id });
141
+ });
142
+ server.registerTool("list_listing_images", {
143
+ description: "List images that have been uploaded to a listing.",
144
+ inputSchema: {
145
+ id: z.string().describe("Listing ID"),
146
+ limit: z.number().int().min(1).max(100).optional().describe("Results per page"),
147
+ cursor: z.string().optional().describe("Pagination cursor"),
148
+ },
149
+ }, async ({ id, limit, cursor }) => {
150
+ const params = new URLSearchParams();
151
+ if (limit !== undefined)
152
+ params.set("limit", String(limit));
153
+ if (cursor)
154
+ params.set("cursor", cursor);
155
+ const qs = params.size ? `?${params}` : "";
156
+ return toText(await vpropFetch(`/listings/${id}/images${qs}`));
157
+ });
158
+ server.registerTool("upload_listing_image", {
159
+ description: "Upload a local image file (JPEG/PNG/WebP) to a listing. " +
160
+ "Gets a presigned URL then performs the binary PUT automatically. " +
161
+ "Minimum 10 images recommended for best video quality.",
162
+ inputSchema: {
163
+ listing_id: z.string().describe("Listing ID to attach the image to"),
164
+ file_path: z.string().describe("Absolute local path to the image file"),
165
+ },
166
+ }, async ({ listing_id, file_path }) => {
167
+ const ext = extname(file_path).toLowerCase();
168
+ const content_type = CONTENT_TYPES[ext];
169
+ if (!content_type) {
170
+ throw new Error(`Unsupported extension '${ext}'. Use .jpg, .jpeg, .png, or .webp`);
171
+ }
172
+ const filename = file_path.split("/").pop() ?? "image.jpg";
173
+ // Read file before any API call — if the file is missing or unreadable
174
+ // we fail here without touching remote state.
175
+ const fileData = readFileSync(file_path);
176
+ const { upload_url, image_id, image_url, expires_at } = await vpropFetch(`/listings/${listing_id}/images/upload-url`, {
177
+ method: "POST",
178
+ body: JSON.stringify({ filename, content_type }),
179
+ });
180
+ // The backend already inserted a DB record. If the binary PUT fails,
181
+ // clean up the orphaned record so the listing doesn't end up with a
182
+ // ghost image that points to an empty S3 object.
183
+ try {
184
+ await uploadBuffer(upload_url, fileData, content_type);
185
+ }
186
+ catch (err) {
187
+ await vpropFetch(`/listings/${listing_id}/images/${image_id}`, {
188
+ method: "DELETE",
189
+ }).catch(() => { });
190
+ throw new Error(`Upload failed: ${err instanceof Error ? err.message : String(err)}. ` +
191
+ `The image record (${image_id}) has been cleaned up.`);
192
+ }
193
+ return toText({ image_id, image_url, expires_at });
194
+ });
195
+ server.registerTool("delete_listing_image", {
196
+ description: "Delete a specific image from a listing.",
197
+ inputSchema: {
198
+ listing_id: z.string().describe("Listing ID"),
199
+ image_id: z.string().describe("Image ID to delete (from list_listing_images)"),
200
+ },
201
+ }, async ({ listing_id, image_id }) => {
202
+ await vpropFetch(`/listings/${listing_id}/images/${image_id}`, { method: "DELETE" });
203
+ return toText({ deleted: true, listing_id, image_id });
204
+ });
205
+ // ── Agents ───────────────────────────────────────────────────────────────────
206
+ server.registerTool("create_agent", {
207
+ description: "Create a realtor agent profile used in video projects. Returns an agent ID (`agt_…`). " +
208
+ "After creation, call upload_agent_photo to add a headshot for the avatar feature.",
209
+ inputSchema: {
210
+ name: z.string().describe("Agent's full name (required)"),
211
+ email: z.string().email().optional().describe("Agent's email address"),
212
+ phone: z.string().optional().describe("Agent's phone number"),
213
+ voice_id: z.string().optional().describe("Default voice ID from list_voices (overridable per-project)"),
214
+ metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary key-value metadata"),
215
+ },
216
+ }, async ({ name, email, phone, voice_id, metadata }) => toText(await vpropFetch("/agents", {
217
+ method: "POST",
218
+ body: JSON.stringify({ name, email, phone, voice_id, metadata }),
219
+ })));
220
+ server.registerTool("get_agent", {
221
+ description: "Get full details of an agent by their ID.",
222
+ inputSchema: {
223
+ id: z.string().describe("Agent ID, e.g. 'agt_MnOpQrStUvWx'"),
224
+ },
225
+ }, async ({ id }) => toText(await vpropFetch(`/agents/${id}`)));
226
+ server.registerTool("list_agents", {
227
+ description: "List all agents with cursor-based pagination.",
228
+ inputSchema: {
229
+ limit: z.number().int().min(1).max(100).optional().describe("Results per page (1–100, default 20)"),
230
+ cursor: z.string().optional().describe("Pagination cursor"),
231
+ },
232
+ }, async ({ limit, cursor }) => {
233
+ const params = new URLSearchParams();
234
+ if (limit !== undefined)
235
+ params.set("limit", String(limit));
236
+ if (cursor)
237
+ params.set("cursor", cursor);
238
+ const qs = params.size ? `?${params}` : "";
239
+ return toText(await vpropFetch(`/agents${qs}`));
240
+ });
241
+ server.registerTool("update_agent", {
242
+ description: "Update an existing agent's information.",
243
+ inputSchema: {
244
+ id: z.string().describe("Agent ID to update"),
245
+ name: z.string().optional(),
246
+ email: z.string().email().optional(),
247
+ phone: z.string().optional(),
248
+ voice_id: z.string().optional().describe("New default voice ID"),
249
+ metadata: z.record(z.string(), z.unknown()).optional(),
250
+ },
251
+ }, async ({ id, name, email, phone, voice_id, metadata }) => toText(await vpropFetch(`/agents/${id}`, {
252
+ method: "PATCH",
253
+ body: JSON.stringify({ name, email, phone, voice_id, metadata }),
254
+ })));
255
+ server.registerTool("delete_agent", {
256
+ description: "Delete an agent by their ID.",
257
+ inputSchema: {
258
+ id: z.string().describe("Agent ID to delete"),
259
+ },
260
+ }, async ({ id }) => {
261
+ await vpropFetch(`/agents/${id}`, { method: "DELETE" });
262
+ return toText({ deleted: true, id });
263
+ });
264
+ server.registerTool("upload_agent_photo", {
265
+ description: "Upload a headshot photo for an agent (JPEG/PNG/WebP). " +
266
+ "Required for the avatar and lipsync features in video projects.",
267
+ inputSchema: {
268
+ agent_id: z.string().describe("Agent ID to attach the photo to"),
269
+ file_path: z.string().describe("Absolute local path to the photo file"),
270
+ },
271
+ }, async ({ agent_id, file_path }) => {
272
+ const ext = extname(file_path).toLowerCase();
273
+ const content_type = CONTENT_TYPES[ext];
274
+ if (!content_type) {
275
+ throw new Error(`Unsupported extension '${ext}'. Use .jpg, .jpeg, .png, or .webp`);
276
+ }
277
+ const filename = file_path.split("/").pop() ?? "photo.jpg";
278
+ // Read file before any API call — the presigned-URL endpoint immediately
279
+ // stamps agents.photo in the DB, so a missing/unreadable file must be
280
+ // caught here before we mutate remote state.
281
+ const fileData = readFileSync(file_path);
282
+ const { upload_url, photo_url } = await vpropFetch(`/agents/${agent_id}/photo/upload-url`, {
283
+ method: "POST",
284
+ body: JSON.stringify({ filename, content_type }),
285
+ });
286
+ // agents.photo is now stamped. If PUT fails, no delete endpoint exists —
287
+ // the only recovery is re-calling this tool with a valid file.
288
+ try {
289
+ await uploadBuffer(upload_url, fileData, content_type);
290
+ }
291
+ catch (err) {
292
+ throw new Error(`Upload failed: ${err instanceof Error ? err.message : String(err)}. ` +
293
+ `The agent's photo URL is now broken. ` +
294
+ `Call upload_agent_photo again with a valid file to recover.`);
295
+ }
296
+ return toText({ photo_url });
297
+ });
298
+ // ── Projects ──────────────────────────────────────────────────────────────────
299
+ server.registerTool("create_project", {
300
+ description: "Start a video generation project (async, returns immediately with status 'pending'). " +
301
+ "Poll get_project_status until 'completed', then call get_project to retrieve the video_url. " +
302
+ "Completion typically takes a few minutes.",
303
+ inputSchema: {
304
+ listing_id: z.string().describe("Listing ID (`lst_…`) with uploaded images"),
305
+ agent_id: z.string().describe("Agent ID (`agt_…`) to feature in the video"),
306
+ title: z.string().max(120).optional().describe("Video title shown in the intro scene (max 120 chars)"),
307
+ cta: z.string().max(120).optional().describe("Call-to-action text in the agent scene (default: 'Schedule a tour')"),
308
+ theme: z.enum(["modern", "classic", "bold", "elegant"]).optional().describe("Visual theme (default: modern)"),
309
+ resolution: z.enum(["portrait", "landscape"]).optional().describe("Video orientation (default: portrait/9:16)"),
310
+ caption: z.enum(["none", "keyword", "description"]).optional().describe("Caption style (default: keyword)"),
311
+ voice_id: z.string().optional().describe("Per-project voice override (from list_voices)"),
312
+ bgm_id: z.string().optional().describe("Background music ID (from list_bgms)"),
313
+ use_agent_avatar: z.boolean().optional().describe("Show animated agent avatar (default: true, needs agent photo)"),
314
+ use_lipsync: z.boolean().optional().describe("Lipsync the avatar (default: false)"),
315
+ metadata: z.record(z.string(), z.unknown()).optional(),
316
+ },
317
+ }, async ({ listing_id, agent_id, title, cta, theme, resolution, caption, voice_id, bgm_id, use_agent_avatar, use_lipsync, metadata, }) => {
318
+ const options = theme || resolution || caption || voice_id || bgm_id || use_agent_avatar !== undefined || use_lipsync !== undefined
319
+ ? { theme, resolution, caption, voice_id, bgm_id, use_agent_avatar, use_lipsync }
320
+ : undefined;
321
+ return toText(await vpropFetch("/projects", {
322
+ method: "POST",
323
+ headers: { "Idempotency-Key": randomUUID() },
324
+ body: JSON.stringify({ listing_id, agent_id, title, cta, options, metadata }),
325
+ }));
326
+ });
327
+ server.registerTool("get_project", {
328
+ description: "Get full project details. When status is 'completed', the response includes `video_url` and `thumbnail_url`.",
329
+ inputSchema: {
330
+ id: z.string().describe("Project ID (`proj_…`)"),
331
+ },
332
+ }, async ({ id }) => toText(await vpropFetch(`/projects/${id}`)));
333
+ server.registerTool("get_project_status", {
334
+ description: "Lightweight status check for a project. Returns one of: pending | processing | completed | failed. " +
335
+ "Use this to poll until completed, then call get_project for the video URL.",
336
+ inputSchema: {
337
+ id: z.string().describe("Project ID (`proj_…`)"),
338
+ },
339
+ }, async ({ id }) => toText(await vpropFetch(`/projects/${id}/status`)));
340
+ server.registerTool("list_projects", {
341
+ description: "List video projects with optional status filter and cursor pagination.",
342
+ inputSchema: {
343
+ status: z
344
+ .enum(["pending", "processing", "completed", "failed"])
345
+ .optional()
346
+ .describe("Filter by project status"),
347
+ limit: z.number().int().min(1).max(100).optional().describe("Results per page"),
348
+ cursor: z.string().optional().describe("Pagination cursor"),
349
+ },
350
+ }, async ({ status, limit, cursor }) => {
351
+ const params = new URLSearchParams();
352
+ if (status)
353
+ params.set("status", status);
354
+ if (limit !== undefined)
355
+ params.set("limit", String(limit));
356
+ if (cursor)
357
+ params.set("cursor", cursor);
358
+ const qs = params.size ? `?${params}` : "";
359
+ return toText(await vpropFetch(`/projects${qs}`));
360
+ });
361
+ // ── Webhooks ──────────────────────────────────────────────────────────────────
362
+ server.registerTool("register_webhook", {
363
+ description: "Register an HTTPS webhook URL to receive project lifecycle events. " +
364
+ "One webhook per API key is allowed (re-registering replaces the previous one). " +
365
+ "Note: GET/DELETE/rotate-secret webhook endpoints are not yet implemented in the API.",
366
+ inputSchema: {
367
+ url: z.string().url().describe("HTTPS endpoint that will receive POST requests"),
368
+ events: z
369
+ .array(z.enum(["project.created", "project.processing", "project.completed", "project.failed"]))
370
+ .min(1)
371
+ .describe("Event types to subscribe to"),
372
+ },
373
+ }, async ({ url, events }) => toText(await vpropFetch("/webhooks", {
374
+ method: "POST",
375
+ body: JSON.stringify({ url, events }),
376
+ })));
377
+ // ── Start ─────────────────────────────────────────────────────────────────────
378
+ const transport = new StdioServerTransport();
379
+ await server.connect(transport);
package/package.json ADDED
@@ -0,0 +1,41 @@
1
+ {
2
+ "name": "@vprop/mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for the vProp Public API — generate real-estate listing videos with AI",
5
+ "type": "module",
6
+ "bin": {
7
+ "vprop-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "prepare": "tsc",
15
+ "dev": "node --experimental-strip-types src/index.ts",
16
+ "start": "node dist/index.js"
17
+ },
18
+ "engines": {
19
+ "node": ">=18"
20
+ },
21
+ "keywords": [
22
+ "mcp",
23
+ "model-context-protocol",
24
+ "vprop",
25
+ "real-estate",
26
+ "ai",
27
+ "video"
28
+ ],
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "license": "MIT",
33
+ "dependencies": {
34
+ "@modelcontextprotocol/sdk": "^1.27.1",
35
+ "zod": "^3.24.0"
36
+ },
37
+ "devDependencies": {
38
+ "@types/node": "^22",
39
+ "typescript": "^5"
40
+ }
41
+ }