@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.
Files changed (3) hide show
  1. package/README.md +15 -2
  2. package/dist/index.js +283 -45
  3. 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.0.0",
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 property address, e.g. '123 Main St, San Francisco, CA 94105' (required)"),
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("Property size in square feet"),
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
- metadata: z.record(z.string(), z.unknown()).optional().describe("Arbitrary key-value metadata"),
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 address, attributes, or metadata.",
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().describe("New property address"),
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.split("/").pop() ?? "image.jpg";
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
- await vpropFetch(`/listings/${listing_id}/images/${image_id}`, {
188
- method: "DELETE",
189
- }).catch(() => { });
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
- `The image record (${image_id}) has been cleaned up.`);
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 add a headshot for the avatar feature.",
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
- 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"),
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 for an agent (JPEG/PNG/WebP). " +
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.split("/").pop() ?? "photo.jpg";
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.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
- language: z.enum(["en", "es", "ko", "zh-Hans"]).optional().describe("Narration + caption language (default: en). Use list_voices to find a voice whose verified_languages includes your chosen language."),
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
- const options = theme || resolution || caption || language || voice_id || bgm_id || use_agent_avatar !== undefined || use_lipsync !== undefined
320
- ? { theme, resolution, caption, language, voice_id, bgm_id, use_agent_avatar, use_lipsync }
321
- : undefined;
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 (re-registering replaces the previous one). " +
366
- "Note: GET/DELETE/rotate-secret webhook endpoints are not yet implemented in the API.",
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
- .describe("Event types to subscribe to"),
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.5",
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
- "prepare": "tsc",
15
+ "prepack": "tsc",
16
16
  "dev": "node --experimental-strip-types src/index.ts",
17
17
  "start": "node dist/index.js"
18
18
  },