@vprop/mcp 1.0.5 → 1.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 +15 -2
- package/dist/index.js +283 -45
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -215,7 +215,15 @@ Any client that follows the [MCP specification](https://modelcontextprotocol.io)
|
|
|
215
215
|
### Webhooks
|
|
216
216
|
| Tool | Description |
|
|
217
217
|
|------|-------------|
|
|
218
|
-
| `register_webhook` | Register an HTTPS URL to receive project events |
|
|
218
|
+
| `register_webhook` | Register an HTTPS URL to receive project events (replaces any existing registration) |
|
|
219
|
+
| `get_webhook` | Get the current webhook registration for this API key |
|
|
220
|
+
| `delete_webhook` | Deactivate a webhook by its ID |
|
|
221
|
+
| `rotate_webhook_secret` | Roll the HMAC signing secret for a webhook |
|
|
222
|
+
|
|
223
|
+
### Usage
|
|
224
|
+
| Tool | Description |
|
|
225
|
+
|------|-------------|
|
|
226
|
+
| `get_usage` | Get usage counts and credit balance for this API key |
|
|
219
227
|
|
|
220
228
|
## Example prompts
|
|
221
229
|
|
|
@@ -234,14 +242,19 @@ When calling `create_project`, you can customize:
|
|
|
234
242
|
|
|
235
243
|
| Option | Values | Default |
|
|
236
244
|
|--------|--------|---------|
|
|
237
|
-
| `theme` | `modern` `classic` `bold` `elegant` | `modern` |
|
|
245
|
+
| `theme` | `modern` `classic` `bold` `elegant` `luxe` `basic` `rosewood` `keystone` `pixel` `editorial` `sketch` | `modern` |
|
|
246
|
+
| `background` | `black` `white` | theme default |
|
|
238
247
|
| `resolution` | `portrait` (9:16) `landscape` (16:9) | `portrait` |
|
|
239
248
|
| `caption` | `none` `keyword` `description` | `keyword` |
|
|
240
249
|
| `language` | `en` `es` `ko` `zh-Hans` | `en` |
|
|
241
250
|
| `voice_id` | ID from `list_voices` | agent default |
|
|
242
251
|
| `bgm_id` | ID from `list_bgms` | theme default |
|
|
252
|
+
| `background_music` | `true` `false` | `true` |
|
|
253
|
+
| `narration_highlights` | free text (≤2000 chars) | — |
|
|
254
|
+
| `word_highlight` | `true` `false` | `false` |
|
|
243
255
|
| `use_agent_avatar` | `true` `false` | `true` |
|
|
244
256
|
| `use_lipsync` | `true` `false` | `false` |
|
|
257
|
+
| `display_info` | object — per-field booleans for agent card | platform defaults |
|
|
245
258
|
|
|
246
259
|
## Notes
|
|
247
260
|
|
package/dist/index.js
CHANGED
|
@@ -2,8 +2,7 @@
|
|
|
2
2
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
3
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
|
-
import { extname } from "node:path";
|
|
6
|
-
import { randomUUID } from "node:crypto";
|
|
5
|
+
import { extname, basename } from "node:path";
|
|
7
6
|
import { z } from "zod";
|
|
8
7
|
const BASE_URL = process.env.VPROP_BASE_URL ?? "https://vprop.ai/api/v1";
|
|
9
8
|
function getApiKey() {
|
|
@@ -54,14 +53,15 @@ async function uploadBuffer(uploadUrl, data, contentType) {
|
|
|
54
53
|
// ────────────────────────────────────────────────────────────────────────────
|
|
55
54
|
const server = new McpServer({
|
|
56
55
|
name: "vprop",
|
|
57
|
-
version: "1.
|
|
56
|
+
version: "1.1.0",
|
|
58
57
|
description: "vProp Public API — create real-estate video projects from listings and agents. " +
|
|
59
58
|
"Typical flow: create_listing → upload_listing_image (10+ photos) → create_agent → " +
|
|
60
59
|
"upload_agent_photo → create_project → poll get_project_status → get_project (for video_url).",
|
|
61
60
|
});
|
|
62
61
|
// ── Voices ───────────────────────────────────────────────────────────────────
|
|
63
62
|
server.registerTool("list_voices", {
|
|
64
|
-
description: "List all available narration voices. Use the returned `id` as `voice_id` when creating agents or projects."
|
|
63
|
+
description: "List all available narration voices. Use the returned `id` as `voice_id` when creating agents or projects. " +
|
|
64
|
+
"Filter on `verified_languages` to find a voice that matches your project's `language` option.",
|
|
65
65
|
}, async () => toText(await vpropFetch("/voices")));
|
|
66
66
|
// ── BGMs ─────────────────────────────────────────────────────────────────────
|
|
67
67
|
server.registerTool("list_bgms", {
|
|
@@ -69,23 +69,62 @@ server.registerTool("list_bgms", {
|
|
|
69
69
|
}, async () => toText(await vpropFetch("/bgms")));
|
|
70
70
|
// ── Listings ─────────────────────────────────────────────────────────────────
|
|
71
71
|
server.registerTool("create_listing", {
|
|
72
|
-
description: "Create a new property listing. Returns a listing ID (`lst_…`) used for image uploads and project creation."
|
|
72
|
+
description: "Create a new property listing. Returns a listing ID (`lst_…`) used for image uploads and project creation. " +
|
|
73
|
+
"The server enriches the listing in parallel: an address-based property search (fills propertyType, lotSize, lat/long, etc.) " +
|
|
74
|
+
"and an LLM pass over the optional `options` free-form text. Merge precedence: explicit body fields > options-parsed > search.",
|
|
73
75
|
inputSchema: {
|
|
74
|
-
address: z.string().describe("Full US
|
|
76
|
+
address: z.string().describe("Full US street address, e.g. '123 Main St' (required)"),
|
|
77
|
+
city: z.string().optional().describe("City name (overrides address search and LLM parse)"),
|
|
78
|
+
state: z.string().optional().describe("State abbreviation, e.g. 'CA' (overrides address search and LLM parse)"),
|
|
79
|
+
zip_code: z.string().optional().describe("ZIP code (overrides address search and LLM parse)"),
|
|
75
80
|
bedrooms: z.number().optional().describe("Number of bedrooms"),
|
|
76
81
|
bathrooms: z.number().optional().describe("Number of bathrooms"),
|
|
77
|
-
square_feet: z.number().optional().describe("
|
|
82
|
+
square_feet: z.number().optional().describe("Living area in square feet"),
|
|
78
83
|
price: z.number().optional().describe("Listing price in USD"),
|
|
79
84
|
year_built: z.number().optional().describe("Year the property was built"),
|
|
80
|
-
|
|
85
|
+
property_status: z
|
|
86
|
+
.enum(["coming soon", "active", "under contract", "pending", "rent", "sold"])
|
|
87
|
+
.optional()
|
|
88
|
+
.describe("Listing status (default: active)"),
|
|
89
|
+
property_type: z
|
|
90
|
+
.enum([
|
|
91
|
+
"single family",
|
|
92
|
+
"townhome",
|
|
93
|
+
"condo",
|
|
94
|
+
"vacant land",
|
|
95
|
+
"multi family",
|
|
96
|
+
"manufactured",
|
|
97
|
+
"co-op",
|
|
98
|
+
"office",
|
|
99
|
+
"retail",
|
|
100
|
+
"industrial",
|
|
101
|
+
"hospitality",
|
|
102
|
+
"mixed-use",
|
|
103
|
+
"restaurant",
|
|
104
|
+
"other",
|
|
105
|
+
])
|
|
106
|
+
.optional()
|
|
107
|
+
.describe("Property type (default: other)"),
|
|
108
|
+
listing_type: z
|
|
109
|
+
.enum(["sale", "rental"])
|
|
110
|
+
.optional()
|
|
111
|
+
.describe("Sale vs rental (default: sale)"),
|
|
112
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary key-value metadata (≤ 4KB)"),
|
|
113
|
+
options: z
|
|
114
|
+
.string()
|
|
115
|
+
.max(8192)
|
|
116
|
+
.optional()
|
|
117
|
+
.describe("Free-form natural language description of the listing " +
|
|
118
|
+
"(e.g. 'renovated kitchen, corner lot, HOA $250/month, listed for sale'). " +
|
|
119
|
+
"The server runs an LLM pass to extract additional fields. Explicit body fields always win."),
|
|
81
120
|
},
|
|
82
|
-
}, async ({ address, bedrooms, bathrooms, square_feet, price, year_built, metadata }) => {
|
|
121
|
+
}, async ({ address, city, state, zip_code, bedrooms, bathrooms, square_feet, price, year_built, property_status, property_type, listing_type, metadata, options }) => {
|
|
83
122
|
const attributes = bedrooms !== undefined || bathrooms !== undefined || square_feet !== undefined || price !== undefined || year_built !== undefined
|
|
84
123
|
? { bedrooms, bathrooms, square_feet, price, year_built }
|
|
85
124
|
: undefined;
|
|
86
125
|
return toText(await vpropFetch("/listings", {
|
|
87
126
|
method: "POST",
|
|
88
|
-
body: JSON.stringify({ address, attributes, metadata }),
|
|
127
|
+
body: JSON.stringify({ address, city, state, zip_code, attributes, property_status, property_type, listing_type, metadata, options }),
|
|
89
128
|
}));
|
|
90
129
|
});
|
|
91
130
|
server.registerTool("get_listing", {
|
|
@@ -110,24 +149,52 @@ server.registerTool("list_listings", {
|
|
|
110
149
|
return toText(await vpropFetch(`/listings${qs}`));
|
|
111
150
|
});
|
|
112
151
|
server.registerTool("update_listing", {
|
|
113
|
-
description: "Update an existing listing's
|
|
152
|
+
description: "Update an existing listing's fields. Only the fields you send are written; the rest keep their current value. " +
|
|
153
|
+
"The address-based property search is NOT re-run on update (updates are partner-driven corrections); " +
|
|
154
|
+
"the LLM `options` parse does run if `options` is provided.",
|
|
114
155
|
inputSchema: {
|
|
115
156
|
id: z.string().describe("Listing ID to update"),
|
|
116
|
-
address: z.string().optional()
|
|
157
|
+
address: z.string().optional(),
|
|
158
|
+
city: z.string().optional(),
|
|
159
|
+
state: z.string().optional(),
|
|
160
|
+
zip_code: z.string().optional(),
|
|
117
161
|
bedrooms: z.number().optional(),
|
|
118
162
|
bathrooms: z.number().optional(),
|
|
119
163
|
square_feet: z.number().optional(),
|
|
120
164
|
price: z.number().optional(),
|
|
121
165
|
year_built: z.number().optional(),
|
|
166
|
+
property_status: z
|
|
167
|
+
.enum(["coming soon", "active", "under contract", "pending", "rent", "sold"])
|
|
168
|
+
.optional(),
|
|
169
|
+
property_type: z
|
|
170
|
+
.enum([
|
|
171
|
+
"single family",
|
|
172
|
+
"townhome",
|
|
173
|
+
"condo",
|
|
174
|
+
"vacant land",
|
|
175
|
+
"multi family",
|
|
176
|
+
"manufactured",
|
|
177
|
+
"co-op",
|
|
178
|
+
"office",
|
|
179
|
+
"retail",
|
|
180
|
+
"industrial",
|
|
181
|
+
"hospitality",
|
|
182
|
+
"mixed-use",
|
|
183
|
+
"restaurant",
|
|
184
|
+
"other",
|
|
185
|
+
])
|
|
186
|
+
.optional(),
|
|
187
|
+
listing_type: z.enum(["sale", "rental"]).optional(),
|
|
122
188
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
189
|
+
options: z.string().max(8192).optional().describe("Free-form text for LLM field extraction"),
|
|
123
190
|
},
|
|
124
|
-
}, async ({ id, address, bedrooms, bathrooms, square_feet, price, year_built, metadata }) => {
|
|
191
|
+
}, async ({ id, address, city, state, zip_code, bedrooms, bathrooms, square_feet, price, year_built, property_status, property_type, listing_type, metadata, options }) => {
|
|
125
192
|
const attributes = bedrooms !== undefined || bathrooms !== undefined || square_feet !== undefined || price !== undefined || year_built !== undefined
|
|
126
193
|
? { bedrooms, bathrooms, square_feet, price, year_built }
|
|
127
194
|
: undefined;
|
|
128
195
|
return toText(await vpropFetch(`/listings/${id}`, {
|
|
129
196
|
method: "PATCH",
|
|
130
|
-
body: JSON.stringify({ address, attributes, metadata }),
|
|
197
|
+
body: JSON.stringify({ address, city, state, zip_code, attributes, property_status, property_type, listing_type, metadata, options }),
|
|
131
198
|
}));
|
|
132
199
|
});
|
|
133
200
|
server.registerTool("delete_listing", {
|
|
@@ -169,7 +236,7 @@ server.registerTool("upload_listing_image", {
|
|
|
169
236
|
if (!content_type) {
|
|
170
237
|
throw new Error(`Unsupported extension '${ext}'. Use .jpg, .jpeg, .png, or .webp`);
|
|
171
238
|
}
|
|
172
|
-
const filename = file_path
|
|
239
|
+
const filename = basename(file_path) || "image.jpg";
|
|
173
240
|
// Read file before any API call — if the file is missing or unreadable
|
|
174
241
|
// we fail here without touching remote state.
|
|
175
242
|
const fileData = readFileSync(file_path);
|
|
@@ -184,11 +251,16 @@ server.registerTool("upload_listing_image", {
|
|
|
184
251
|
await uploadBuffer(upload_url, fileData, content_type);
|
|
185
252
|
}
|
|
186
253
|
catch (err) {
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
254
|
+
let cleanedUp = false;
|
|
255
|
+
try {
|
|
256
|
+
await vpropFetch(`/listings/${listing_id}/images/${image_id}`, { method: "DELETE" });
|
|
257
|
+
cleanedUp = true;
|
|
258
|
+
}
|
|
259
|
+
catch { }
|
|
190
260
|
throw new Error(`Upload failed: ${err instanceof Error ? err.message : String(err)}. ` +
|
|
191
|
-
|
|
261
|
+
(cleanedUp
|
|
262
|
+
? `The image record (${image_id}) has been cleaned up.`
|
|
263
|
+
: `The image record (${image_id}) may still exist — call delete_listing_image to remove it.`));
|
|
192
264
|
}
|
|
193
265
|
return toText({ image_id, image_url, expires_at });
|
|
194
266
|
});
|
|
@@ -205,17 +277,51 @@ server.registerTool("delete_listing_image", {
|
|
|
205
277
|
// ── Agents ───────────────────────────────────────────────────────────────────
|
|
206
278
|
server.registerTool("create_agent", {
|
|
207
279
|
description: "Create a realtor agent profile used in video projects. Returns an agent ID (`agt_…`). " +
|
|
208
|
-
"After creation, call upload_agent_photo to
|
|
280
|
+
"After creation, call upload_agent_photo to upload a local headshot file, or pass profile_photo_url if the photo is already hosted.",
|
|
209
281
|
inputSchema: {
|
|
210
282
|
name: z.string().describe("Agent's full name (required)"),
|
|
211
283
|
email: z.string().email().optional().describe("Agent's email address"),
|
|
212
|
-
phone: z.string().optional().describe("Agent's phone number"),
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
phone: z.string().optional().describe("Agent's direct phone number"),
|
|
285
|
+
profile_photo_url: z
|
|
286
|
+
.string()
|
|
287
|
+
.url()
|
|
288
|
+
.optional()
|
|
289
|
+
.describe("URL of an already-hosted headshot photo. " +
|
|
290
|
+
"Use this when you have a CDN/web URL. " +
|
|
291
|
+
"To upload a local file instead, call upload_agent_photo after creating the agent."),
|
|
292
|
+
license_number: z.string().optional().describe("Real-estate license / DRE / RECO number"),
|
|
293
|
+
website: z.string().optional().describe("Agent or business website URL"),
|
|
294
|
+
area: z.string().optional().describe("Service area or neighborhoods covered"),
|
|
295
|
+
team_name: z.string().optional().describe("Team name within the brokerage"),
|
|
296
|
+
company_name: z.string().optional().describe("Brokerage / company name"),
|
|
297
|
+
brokerage_phone: z
|
|
298
|
+
.string()
|
|
299
|
+
.optional()
|
|
300
|
+
.describe("Brokerage office phone (distinct from the agent's direct phone)"),
|
|
301
|
+
bio: z.string().optional().describe("Short agent biography"),
|
|
302
|
+
logo_url: z
|
|
303
|
+
.string()
|
|
304
|
+
.url()
|
|
305
|
+
.optional()
|
|
306
|
+
.describe("Agent logo image URL (shown on the agent card in the video)"),
|
|
307
|
+
team_logo_url: z.string().url().optional().describe("Team logo image URL"),
|
|
308
|
+
company_logo_url: z.string().url().optional().describe("Company / brokerage logo image URL"),
|
|
309
|
+
voice_id: z
|
|
310
|
+
.string()
|
|
311
|
+
.optional()
|
|
312
|
+
.describe("Default voice ID from list_voices. Used as the fallback when a project omits voice_id."),
|
|
313
|
+
metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary key-value metadata (≤ 4KB)"),
|
|
314
|
+
options: z
|
|
315
|
+
.string()
|
|
316
|
+
.max(4096)
|
|
317
|
+
.optional()
|
|
318
|
+
.describe("Free-form natural language description of the agent " +
|
|
319
|
+
"(e.g. 'Team name is The Smith Group at Compass. DRE #01234567. Serves the SF Bay Area.'). " +
|
|
320
|
+
"The server runs an LLM pass to extract additional profile fields. Explicit body fields always win."),
|
|
215
321
|
},
|
|
216
|
-
}, async ({ name, email, phone, voice_id, metadata }) => toText(await vpropFetch("/agents", {
|
|
322
|
+
}, async ({ name, email, phone, profile_photo_url, license_number, website, area, team_name, company_name, brokerage_phone, bio, logo_url, team_logo_url, company_logo_url, voice_id, metadata, options }) => toText(await vpropFetch("/agents", {
|
|
217
323
|
method: "POST",
|
|
218
|
-
body: JSON.stringify({ name, email, phone, voice_id, metadata }),
|
|
324
|
+
body: JSON.stringify({ name, email, phone, profile_photo_url, license_number, website, area, team_name, company_name, brokerage_phone, bio, logo_url, team_logo_url, company_logo_url, voice_id, metadata, options }),
|
|
219
325
|
})));
|
|
220
326
|
server.registerTool("get_agent", {
|
|
221
327
|
description: "Get full details of an agent by their ID.",
|
|
@@ -239,18 +345,34 @@ server.registerTool("list_agents", {
|
|
|
239
345
|
return toText(await vpropFetch(`/agents${qs}`));
|
|
240
346
|
});
|
|
241
347
|
server.registerTool("update_agent", {
|
|
242
|
-
description: "Update an existing agent's information.",
|
|
348
|
+
description: "Update an existing agent's information. Only the fields you send are written; the rest keep their current value.",
|
|
243
349
|
inputSchema: {
|
|
244
350
|
id: z.string().describe("Agent ID to update"),
|
|
245
351
|
name: z.string().optional(),
|
|
246
352
|
email: z.string().email().optional(),
|
|
247
353
|
phone: z.string().optional(),
|
|
354
|
+
profile_photo_url: z
|
|
355
|
+
.string()
|
|
356
|
+
.url()
|
|
357
|
+
.optional()
|
|
358
|
+
.describe("URL of an already-hosted headshot photo. To upload a local file instead, call upload_agent_photo."),
|
|
359
|
+
license_number: z.string().optional(),
|
|
360
|
+
website: z.string().optional(),
|
|
361
|
+
area: z.string().optional(),
|
|
362
|
+
team_name: z.string().optional(),
|
|
363
|
+
company_name: z.string().optional(),
|
|
364
|
+
brokerage_phone: z.string().optional(),
|
|
365
|
+
bio: z.string().optional(),
|
|
366
|
+
logo_url: z.string().url().optional(),
|
|
367
|
+
team_logo_url: z.string().url().optional(),
|
|
368
|
+
company_logo_url: z.string().url().optional(),
|
|
248
369
|
voice_id: z.string().optional().describe("New default voice ID"),
|
|
249
370
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
371
|
+
options: z.string().max(4096).optional().describe("Free-form text for LLM field extraction"),
|
|
250
372
|
},
|
|
251
|
-
}, async ({ id, name, email, phone, voice_id, metadata }) => toText(await vpropFetch(`/agents/${id}`, {
|
|
373
|
+
}, async ({ id, name, email, phone, profile_photo_url, license_number, website, area, team_name, company_name, brokerage_phone, bio, logo_url, team_logo_url, company_logo_url, voice_id, metadata, options }) => toText(await vpropFetch(`/agents/${id}`, {
|
|
252
374
|
method: "PATCH",
|
|
253
|
-
body: JSON.stringify({ name, email, phone, voice_id, metadata }),
|
|
375
|
+
body: JSON.stringify({ name, email, phone, profile_photo_url, license_number, website, area, team_name, company_name, brokerage_phone, bio, logo_url, team_logo_url, company_logo_url, voice_id, metadata, options }),
|
|
254
376
|
})));
|
|
255
377
|
server.registerTool("delete_agent", {
|
|
256
378
|
description: "Delete an agent by their ID.",
|
|
@@ -262,8 +384,9 @@ server.registerTool("delete_agent", {
|
|
|
262
384
|
return toText({ deleted: true, id });
|
|
263
385
|
});
|
|
264
386
|
server.registerTool("upload_agent_photo", {
|
|
265
|
-
description: "Upload a headshot photo
|
|
266
|
-
"Required for the avatar and lipsync features in video projects."
|
|
387
|
+
description: "Upload a headshot photo from a local file (JPEG/PNG/WebP) for an agent. " +
|
|
388
|
+
"Required for the avatar and lipsync features in video projects. " +
|
|
389
|
+
"If the photo is already hosted at a URL, pass `profile_photo_url` in create_agent or update_agent instead.",
|
|
267
390
|
inputSchema: {
|
|
268
391
|
agent_id: z.string().describe("Agent ID to attach the photo to"),
|
|
269
392
|
file_path: z.string().describe("Absolute local path to the photo file"),
|
|
@@ -274,7 +397,7 @@ server.registerTool("upload_agent_photo", {
|
|
|
274
397
|
if (!content_type) {
|
|
275
398
|
throw new Error(`Unsupported extension '${ext}'. Use .jpg, .jpeg, .png, or .webp`);
|
|
276
399
|
}
|
|
277
|
-
const filename = file_path
|
|
400
|
+
const filename = basename(file_path) || "photo.jpg";
|
|
278
401
|
// Read file before any API call — the presigned-URL endpoint immediately
|
|
279
402
|
// stamps agents.photo in the DB, so a missing/unreadable file must be
|
|
280
403
|
// caught here before we mutate remote state.
|
|
@@ -305,23 +428,86 @@ server.registerTool("create_project", {
|
|
|
305
428
|
agent_id: z.string().describe("Agent ID (`agt_…`) to feature in the video"),
|
|
306
429
|
title: z.string().max(120).optional().describe("Video title shown in the intro scene (max 120 chars)"),
|
|
307
430
|
cta: z.string().max(120).optional().describe("Call-to-action text in the agent scene (default: 'Schedule a tour')"),
|
|
308
|
-
theme: z
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
431
|
+
theme: z
|
|
432
|
+
.enum(["modern", "classic", "bold", "elegant", "luxe", "basic", "rosewood", "keystone", "pixel", "editorial", "sketch"])
|
|
433
|
+
.optional()
|
|
434
|
+
.describe("Visual theme (default: modern). `basic` is an unbranded template."),
|
|
435
|
+
background: z
|
|
436
|
+
.enum(["black", "white"])
|
|
437
|
+
.optional()
|
|
438
|
+
.describe("Scene background color. Defaults to the theme's natural background (white for `pixel`, black otherwise)."),
|
|
439
|
+
resolution: z
|
|
440
|
+
.enum(["portrait", "landscape"])
|
|
441
|
+
.optional()
|
|
442
|
+
.describe("Video orientation. portrait = 1080×1920 (Reels/TikTok); landscape = 1920×1080. Default: portrait."),
|
|
443
|
+
caption: z
|
|
444
|
+
.enum(["none", "keyword", "description"])
|
|
445
|
+
.optional()
|
|
446
|
+
.describe("Caption style. none = audio only; keyword = short pulses; description = full sentences. Default: keyword."),
|
|
447
|
+
language: z
|
|
448
|
+
.enum(["en", "es", "ko", "zh-Hans"])
|
|
449
|
+
.optional()
|
|
450
|
+
.describe("Narration + caption language (BCP-47). Default: en. " +
|
|
451
|
+
"Use list_voices and filter on verified_languages to find a matching voice."),
|
|
312
452
|
voice_id: z.string().optional().describe("Per-project voice override (from list_voices)"),
|
|
313
|
-
bgm_id: z.string().optional().describe("Background music ID (from list_bgms)"),
|
|
453
|
+
bgm_id: z.string().optional().describe("Background music ID (from list_bgms). Unknown IDs return 400."),
|
|
314
454
|
use_agent_avatar: z.boolean().optional().describe("Show animated agent avatar (default: true, needs agent photo)"),
|
|
315
|
-
use_lipsync: z.boolean().optional().describe("Lipsync the avatar (default: false)"),
|
|
455
|
+
use_lipsync: z.boolean().optional().describe("Lipsync the avatar to the narration (default: false, adds render time)"),
|
|
456
|
+
background_music: z
|
|
457
|
+
.boolean()
|
|
458
|
+
.optional()
|
|
459
|
+
.describe("Play background music under the video. Default: true. Set false for a music-free render."),
|
|
460
|
+
narration_highlights: z
|
|
461
|
+
.string()
|
|
462
|
+
.max(2000)
|
|
463
|
+
.optional()
|
|
464
|
+
.describe("Free-text key points to emphasize in the narration script " +
|
|
465
|
+
"(e.g. 'newly renovated kitchen, corner lot, walking distance to BART'). " +
|
|
466
|
+
"The model weaves these into the relevant scenes."),
|
|
467
|
+
word_highlight: z
|
|
468
|
+
.boolean()
|
|
469
|
+
.optional()
|
|
470
|
+
.describe("Render gallery captions word-by-word, synced to the narration. " +
|
|
471
|
+
"Supported on all themes except `basic`. Default: false."),
|
|
472
|
+
display_info: z
|
|
473
|
+
.object({
|
|
474
|
+
name: z.boolean().optional(),
|
|
475
|
+
email: z.boolean().optional(),
|
|
476
|
+
logo: z.boolean().optional(),
|
|
477
|
+
contact: z.boolean().optional(),
|
|
478
|
+
license_number: z.boolean().optional(),
|
|
479
|
+
team_info: z.boolean().optional(),
|
|
480
|
+
company_info: z.boolean().optional(),
|
|
481
|
+
company_name: z.boolean().optional(),
|
|
482
|
+
brokerage_phone: z.boolean().optional(),
|
|
483
|
+
})
|
|
484
|
+
.optional()
|
|
485
|
+
.describe("Control which agent-card fields appear on screen. " +
|
|
486
|
+
"Each flag defaults to the platform default when omitted (most on; email off). " +
|
|
487
|
+
"Useful for MLS/compliance rules that require hiding specific fields."),
|
|
316
488
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
317
489
|
},
|
|
318
|
-
}, async ({ listing_id, agent_id, title, cta, theme, resolution, caption, language, voice_id, bgm_id, use_agent_avatar, use_lipsync, metadata, }) => {
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
490
|
+
}, async ({ listing_id, agent_id, title, cta, theme, background, resolution, caption, language, voice_id, bgm_id, use_agent_avatar, use_lipsync, background_music, narration_highlights, word_highlight, display_info, metadata, }) => {
|
|
491
|
+
// Build options object — use Object.values check so falsy booleans
|
|
492
|
+
// (background_music:false, word_highlight:false) are not silently dropped.
|
|
493
|
+
const optionFields = {
|
|
494
|
+
theme,
|
|
495
|
+
background,
|
|
496
|
+
resolution,
|
|
497
|
+
caption,
|
|
498
|
+
language,
|
|
499
|
+
voice_id,
|
|
500
|
+
bgm_id,
|
|
501
|
+
use_agent_avatar,
|
|
502
|
+
use_lipsync,
|
|
503
|
+
background_music,
|
|
504
|
+
narration_highlights,
|
|
505
|
+
word_highlight,
|
|
506
|
+
display_info,
|
|
507
|
+
};
|
|
508
|
+
const options = Object.values(optionFields).some((v) => v !== undefined) ? optionFields : undefined;
|
|
322
509
|
return toText(await vpropFetch("/projects", {
|
|
323
510
|
method: "POST",
|
|
324
|
-
headers: { "Idempotency-Key": randomUUID() },
|
|
325
511
|
body: JSON.stringify({ listing_id, agent_id, title, cta, options, metadata }),
|
|
326
512
|
}));
|
|
327
513
|
});
|
|
@@ -362,19 +548,71 @@ server.registerTool("list_projects", {
|
|
|
362
548
|
// ── Webhooks ──────────────────────────────────────────────────────────────────
|
|
363
549
|
server.registerTool("register_webhook", {
|
|
364
550
|
description: "Register an HTTPS webhook URL to receive project lifecycle events. " +
|
|
365
|
-
"One webhook per API key is allowed
|
|
366
|
-
"
|
|
551
|
+
"One active webhook per API key is allowed — re-registering replaces the previous one. " +
|
|
552
|
+
"The response includes a `secret` used to verify the `X-VProp-Signature` HMAC on each delivery; " +
|
|
553
|
+
"it is returned only once, so store it immediately. " +
|
|
554
|
+
"Use get_webhook to inspect the current registration, delete_webhook to stop deliveries, " +
|
|
555
|
+
"and rotate_webhook_secret to roll the signing secret.",
|
|
367
556
|
inputSchema: {
|
|
368
557
|
url: z.string().url().describe("HTTPS endpoint that will receive POST requests"),
|
|
369
558
|
events: z
|
|
370
559
|
.array(z.enum(["project.created", "project.processing", "project.completed", "project.failed"]))
|
|
371
560
|
.min(1)
|
|
372
|
-
.
|
|
561
|
+
.optional()
|
|
562
|
+
.describe("Event types to subscribe to. Omit to subscribe to all events."),
|
|
373
563
|
},
|
|
374
564
|
}, async ({ url, events }) => toText(await vpropFetch("/webhooks", {
|
|
375
565
|
method: "POST",
|
|
376
566
|
body: JSON.stringify({ url, events }),
|
|
377
567
|
})));
|
|
568
|
+
server.registerTool("get_webhook", {
|
|
569
|
+
description: "Get the current webhook registration for this API key. " +
|
|
570
|
+
"The signing secret is NOT returned here (it is shown only once, at registration or after rotate_webhook_secret). " +
|
|
571
|
+
"Returns 404 if no webhook is registered.",
|
|
572
|
+
}, async () => toText(await vpropFetch("/webhooks")));
|
|
573
|
+
server.registerTool("delete_webhook", {
|
|
574
|
+
description: "Deactivate (soft-delete) a webhook by its ID. After this, no further events are delivered. " +
|
|
575
|
+
"Idempotent-ish: a second delete of the same ID returns 404 (already inactive). " +
|
|
576
|
+
"Use get_webhook to find the current webhook ID.",
|
|
577
|
+
inputSchema: {
|
|
578
|
+
id: z.string().describe("Webhook ID (UUID, from register_webhook or get_webhook)"),
|
|
579
|
+
},
|
|
580
|
+
}, async ({ id }) => toText(await vpropFetch(`/webhooks/${id}`, { method: "DELETE" })));
|
|
581
|
+
server.registerTool("rotate_webhook_secret", {
|
|
582
|
+
description: "Issue a new HMAC signing secret for an existing webhook, keeping its URL and events unchanged. " +
|
|
583
|
+
"The new `secret` is returned once and the previous secret is immediately invalidated. " +
|
|
584
|
+
"Deliveries sent between rotation and your partner updating their stored secret will fail signature verification " +
|
|
585
|
+
"(they will be retried by the server). " +
|
|
586
|
+
"Use get_webhook to find the current webhook ID.",
|
|
587
|
+
inputSchema: {
|
|
588
|
+
id: z.string().describe("Webhook ID (UUID, from register_webhook or get_webhook)"),
|
|
589
|
+
},
|
|
590
|
+
}, async ({ id }) => toText(await vpropFetch(`/webhooks/${id}/rotate-secret`, { method: "POST" })));
|
|
591
|
+
// ── Usage ─────────────────────────────────────────────────────────────────────
|
|
592
|
+
server.registerTool("get_usage", {
|
|
593
|
+
description: "Get this API key's usage statistics and credit balance. " +
|
|
594
|
+
"Returns counts of projects, listings, and agents created, plus credits consumed, over a time window. " +
|
|
595
|
+
"For prepaid keys, also returns the team's current `credit_balance`. " +
|
|
596
|
+
"Defaults to the last 30 days when `from`/`to` are omitted.",
|
|
597
|
+
inputSchema: {
|
|
598
|
+
from: z
|
|
599
|
+
.string()
|
|
600
|
+
.optional()
|
|
601
|
+
.describe("Start of the reporting window, ISO 8601 (e.g. '2026-06-01T00:00:00Z'). Default: 30 days ago."),
|
|
602
|
+
to: z
|
|
603
|
+
.string()
|
|
604
|
+
.optional()
|
|
605
|
+
.describe("End of the reporting window, ISO 8601. Default: now."),
|
|
606
|
+
},
|
|
607
|
+
}, async ({ from, to }) => {
|
|
608
|
+
const params = new URLSearchParams();
|
|
609
|
+
if (from)
|
|
610
|
+
params.set("from", from);
|
|
611
|
+
if (to)
|
|
612
|
+
params.set("to", to);
|
|
613
|
+
const qs = params.size ? `?${params}` : "";
|
|
614
|
+
return toText(await vpropFetch(`/usage${qs}`));
|
|
615
|
+
});
|
|
378
616
|
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
379
617
|
const transport = new StdioServerTransport();
|
|
380
618
|
await server.connect(transport);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vprop/mcp",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "MCP server for the vProp Public API — generate real-estate listing videos with AI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
],
|
|
13
13
|
"scripts": {
|
|
14
14
|
"build": "tsc",
|
|
15
|
-
"
|
|
15
|
+
"prepack": "tsc",
|
|
16
16
|
"dev": "node --experimental-strip-types src/index.ts",
|
|
17
17
|
"start": "node dist/index.js"
|
|
18
18
|
},
|