@meterapp/car-image-sdk 1.0.2 → 1.2.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/dist/mcp/index.js CHANGED
@@ -10,17 +10,25 @@
10
10
  * entry point of the SDK has no runtime dependencies.
11
11
  */
12
12
  import * as z from "zod";
13
- import { COLORS, CREDITS_PER_DOLLAR, CREDITS_PER_IMAGE, DEFAULT_BASE_URL, FORMATS, FREE_CREDITS, MAX_DIMENSION, MIN_YEAR, SIZES, VIEWS, } from "../types.js";
13
+ import { CATALOG, COLORS, CREDITS_PER_DOLLAR, CREDITS_PER_IMAGE, DEFAULT_BASE_URL, FORMATS, FREE_CREDITS, MAX_DIMENSION, MIN_YEAR, REFERRAL_SOURCES, REQUEST_KINDS, REQUEST_SORTS, REQUEST_STATUSES, SIZES, VIEWS, } from "../types.js";
14
14
  import { SDK_VERSION } from "../version.js";
15
+ /**
16
+ * Catalog size as agents read it in the instructions and tool descriptions.
17
+ * Derived from CATALOG so the figures cannot drift from the database; agents
18
+ * pick tools off these numbers, so understating them costs real calls.
19
+ */
20
+ const CATALOG_MAKES = CATALOG.makes.toLocaleString("en-US");
21
+ const CATALOG_MODELS = CATALOG.models.toLocaleString("en-US");
22
+ const CATALOG_YEARS = `${CATALOG.yearFrom}-${CATALOG.yearTo}`;
15
23
  export const MCP_SERVER_INFO = {
16
24
  name: "car-image-api",
17
25
  title: "Car Image API",
18
26
  version: SDK_VERSION,
19
27
  websiteUrl: DEFAULT_BASE_URL,
20
28
  };
21
- export const MCP_INSTRUCTIONS = `Car Image API by Meter: studio-quality, transparent-background renders of any vehicle in the open @meterapp/vehicle-db catalog (1,145 makes, 14,841 models, model years 1990-2026). Six camera views, fifteen preset colors, PNG/WebP/JPG up to 1024 px.
29
+ export const MCP_INSTRUCTIONS = `Car Image API by Meter: studio-quality, transparent-background renders of any vehicle in the open @meterapp/vehicle-db catalog (${CATALOG_MAKES} makes, ${CATALOG_MODELS} models, model years ${CATALOG_YEARS}). Six camera views, fifteen preset colors, PNG/WebP/JPG up to 1024 px.
22
30
 
23
- Costs: every delivered image costs exactly ${CREDITS_PER_IMAGE} credit ($1 = ${CREDITS_PER_DOLLAR.toLocaleString("en-US")} credits; ${FREE_CREDITS} free credits per account), whether it is served from cache or generated on demand. create_car_image_urls charges 1 credit per URL when the URL is created; redemptions are free. search_vehicles, resolve_vehicle, list_image_options, get_account, rate_image and describe_api are free.
31
+ Costs: every delivered image costs exactly ${CREDITS_PER_IMAGE} credit ($1 = ${CREDITS_PER_DOLLAR.toLocaleString("en-US")} credits; ${FREE_CREDITS} free credits per account), cold or CDN hit. create_car_image_urls charges 1 credit per URL when the URL is created; redemptions are free. search_vehicles, resolve_vehicle, list_image_options, get_account, rate_image and describe_api are free.
24
32
 
25
33
  Workflow: (1) resolve_vehicle turns free text such as "red 2024 porsche 911 side view" into exact parameters; search_vehicles browses the catalog. (2) Call get_car_image when the image itself is needed in the conversation. (3) For web pages, emails, documents or anything a browser will load, call create_car_image_urls and embed the signed URLs; they need no key.
26
34
 
@@ -28,7 +36,9 @@ Security: never place the API key in URLs, HTML, logs, screenshots or client-sid
28
36
 
29
37
  Money: on an insufficient-credits error (HTTP 402) stop and ask the human to buy credits at ${DEFAULT_BASE_URL}/billing or with \`car-image billing\`; never purchase credits autonomously. On a rate limit (HTTP 429) wait the number of seconds in retry_after before trying again.
30
38
 
31
- Quality: after a human judges a render, call rate_image (good/bad or 1-5) so future renders improve.`;
39
+ Quality: after a human judges a render, call rate_image (good/bad or 1-5) so future renders improve.
40
+
41
+ Requests: when search_vehicles or resolve_vehicle finds no match for a vehicle the human needs, offer request_vehicle so it gets added (an existing open request for the same car is upvoted instead of duplicated); ideas for the API go through request_feature, and list_requests shows what others have asked for.`;
32
42
  const yearSchema = z.coerce
33
43
  .number()
34
44
  .int()
@@ -77,6 +87,17 @@ export const MCP_TOOL_SCHEMAS = {
77
87
  .max(1_000_000)
78
88
  .optional()
79
89
  .describe("Maximum redemptions per URL; 0 (default) means unlimited until expiry."),
90
+ renew: z
91
+ .boolean()
92
+ .optional()
93
+ .describe("Auto-renew past the TTL: the first load in each further window of ttl_seconds bills one more credit and renews the URL, until renew_days. Use for email, CMS pages and documents that outlive a TTL; pair with ttl_seconds 604800 so the cost ceiling is 1 credit per image per opened week. Requires max_uses 0."),
94
+ renew_days: z
95
+ .number()
96
+ .int()
97
+ .min(1)
98
+ .max(365)
99
+ .optional()
100
+ .describe("How long a renewable URL keeps renewing, 1 to 365 days from creation (default 365). Only with renew: true."),
80
101
  }),
81
102
  search_vehicles: z.object({
82
103
  query: z
@@ -119,27 +140,328 @@ export const MCP_TOOL_SCHEMAS = {
119
140
  .optional()
120
141
  .describe('Filter by path fragment or keyword, e.g. "images/car", "image-urls", "feedback". Omit for the full compact reference.'),
121
142
  }),
143
+ list_requests: z.object({
144
+ kind: z.enum(REQUEST_KINDS).optional().describe("Only vehicle requests or only feature requests. Omit for both."),
145
+ status: z
146
+ .enum(REQUEST_STATUSES)
147
+ .optional()
148
+ .describe("Filter by status. Omit for everything that is not declined."),
149
+ sort: z.enum(REQUEST_SORTS).default("top").describe("top = most upvotes first (default); new = most recent first."),
150
+ limit: z.number().int().min(1).max(100).default(25).describe("Maximum number of requests (1-100)."),
151
+ cursor: z.string().max(200).optional().describe("next_cursor from a previous page, to read the next one."),
152
+ }),
153
+ request_vehicle: z.object({
154
+ make: z.string().min(1).max(80).describe('Manufacturer, e.g. "Rivian". Case does not matter.'),
155
+ model: z.string().min(1).max(80).describe('Model, e.g. "R1T".'),
156
+ year: z.coerce.number().int().min(1900).max(2035).optional().describe("Model year, if it matters. Omit to ask for the model in general."),
157
+ note: z.string().max(2000).optional().describe("Why it is needed, body style, trim; anything that helps prioritise."),
158
+ }),
159
+ request_feature: z.object({
160
+ title: z.string().min(1).max(120).describe('Short summary, e.g. "Interior views".'),
161
+ body: z.string().max(2000).optional().describe("Details: the use case, what the output should look like."),
162
+ }),
163
+ get_request: z.object({
164
+ id: z.string().uuid().describe("The request id from list_requests, request_vehicle or request_feature."),
165
+ }),
166
+ upvote_request: z.object({
167
+ id: z.string().uuid().describe("The request id to upvote. Idempotent."),
168
+ }),
169
+ comment_on_request: z.object({
170
+ id: z.string().uuid().describe("The request id to comment on."),
171
+ body: z.string().min(1).max(1000).describe("The comment (1-1000 characters)."),
172
+ }),
173
+ share_building: z.object({
174
+ building: z.string().min(1).max(2000).describe("What the human is building with the API, in their words (1-2000 characters)."),
175
+ }),
176
+ share_referral: z.object({
177
+ source: z.enum(REFERRAL_SOURCES).describe("Where the human first heard about Car Image API."),
178
+ detail: z.string().max(500).optional().describe('Optional specifics, e.g. the article, the person, or the search query.'),
179
+ }),
122
180
  };
181
+ /* --------------------------------------------------------------- outputs ---
182
+ *
183
+ * A tool may advertise an `outputSchema` (MCP 2025-06-18 and later). Hosts show
184
+ * it next to the input schema, validate `structuredContent` against it, and can
185
+ * hand the model typed data instead of re-parsing the text block. The server SDK
186
+ * enforces the promise from the other side: a tool that declares an output
187
+ * schema and then returns no structured content, or content that does not match,
188
+ * fails the call. Two rules follow from that, and both are load-bearing:
189
+ *
190
+ * - Every object here is a `looseObject`. These payloads are the REST API's,
191
+ * which gains fields as the product does (`usage_30d`, `year_ranges`,
192
+ * `limits`). A strict object advertises `additionalProperties: false`, which
193
+ * would make a validating client reject a response for being newer than it.
194
+ * - Only what BOTH servers guarantee is required. Where the remote server and
195
+ * the stdio one disagreed, they were made to agree (`toVehicleMatch`,
196
+ * `toImageOptions`, `etag` and `credits_usd` in the stdio handlers) rather
197
+ * than the schema being weakened to cover both.
198
+ *
199
+ * `describe_api` deliberately has none: its result *is* the reference prose, and
200
+ * restating it as structured content would double the tokens of every call.
201
+ */
202
+ /** The vehicle echoed back with an image: exactly what would render it again. */
203
+ const vehicleDescriptorSchema = z.looseObject({
204
+ make: z.string(),
205
+ model: z.string(),
206
+ year: z.number().int(),
207
+ view: z.enum(VIEWS),
208
+ color: z.enum(COLORS),
209
+ width: z.number().int().optional(),
210
+ height: z.number().int().optional(),
211
+ format: z.enum(FORMATS),
212
+ });
213
+ /** `free_credits` is absent from the account endpoint's copy; the packs are not. */
214
+ const pricingSchema = z.looseObject({
215
+ credits_per_image: z.number(),
216
+ credits_per_dollar: z.number(),
217
+ free_credits: z.number().optional(),
218
+ packs: z
219
+ .array(z.looseObject({ credits: z.number().int(), cents: z.number().int(), label: z.string().optional() }))
220
+ .optional(),
221
+ });
222
+ /** One catalog hit, in the single spelling both servers emit (see {@link toVehicleMatch}). */
223
+ const vehicleMatchSchema = z.looseObject({
224
+ kind: z.enum(["make", "model"]),
225
+ make: z.string(),
226
+ make_slug: z.string(),
227
+ model: z.string().nullable(),
228
+ model_slug: z.string().nullable(),
229
+ vehicle_type: z.string().nullable(),
230
+ years: z.array(z.number().int()),
231
+ match_kind: z.string().nullable(),
232
+ });
233
+ /** Shared by the list/create/vote results and, minus the count, by the detail. */
234
+ const requestFields = {
235
+ id: z.string(),
236
+ kind: z.enum(REQUEST_KINDS),
237
+ title: z.string(),
238
+ body: z.string().nullable(),
239
+ vehicle: z.looseObject({ make: z.string(), model: z.string(), year: z.number().int().nullable() }).nullable(),
240
+ status: z.enum(REQUEST_STATUSES),
241
+ votes: z.number().int(),
242
+ author: z.string(),
243
+ url: z.string(),
244
+ created_at: z.string(),
245
+ updated_at: z.string(),
246
+ viewer: z.looseObject({ voted: z.boolean() }).optional(),
247
+ };
248
+ const requestCommentSchema = z.looseObject({
249
+ id: z.string(),
250
+ request_id: z.string(),
251
+ body: z.string(),
252
+ author: z.string(),
253
+ created_at: z.string(),
254
+ });
255
+ const requestRecordSchema = z.looseObject({ ...requestFields, comments: z.number().int() });
256
+ /** On the detail the thread takes the `comments` name, so the count moves aside. */
257
+ const requestDetailSchema = z.looseObject({
258
+ ...requestFields,
259
+ comment_count: z.number().int(),
260
+ comments: z.array(requestCommentSchema),
261
+ });
262
+ const requestListResult = z.looseObject({
263
+ data: z.array(requestRecordSchema),
264
+ next_cursor: z.string().nullable(),
265
+ request_id: z.string(),
266
+ });
267
+ const requestResult = z.looseObject({ data: requestRecordSchema, request_id: z.string() });
268
+ const createdRequestResult = z.looseObject({
269
+ data: requestRecordSchema,
270
+ /** True when an open vehicle request already existed and took the vote instead. */
271
+ deduplicated: z.boolean(),
272
+ request_id: z.string(),
273
+ });
274
+ export const MCP_TOOL_OUTPUT_SCHEMAS = {
275
+ get_car_image: z.looseObject({
276
+ credits_charged: z.number().nullable(),
277
+ credits_remaining: z.number().nullable(),
278
+ source: z.enum(["cache", "generated"]).nullable(),
279
+ width: z.number().int().nullable(),
280
+ height: z.number().int().nullable(),
281
+ content_type: z.string(),
282
+ etag: z.string().nullable(),
283
+ request_id: z.string().nullable(),
284
+ vehicle: vehicleDescriptorSchema,
285
+ }),
286
+ create_car_image_urls: z.looseObject({
287
+ data: z.array(z.looseObject({
288
+ id: z.string(),
289
+ /** Signed delivery URL; needs no key and is safe to put in HTML. */
290
+ url: z.string(),
291
+ expires_at: z.string(),
292
+ /** 0 means unlimited redemptions until it expires. */
293
+ max_uses: z.number().int(),
294
+ /** Auto-renewing URLs keep serving, one credit per opened window, until this instant. */
295
+ renews_until: z.string().nullable().optional(),
296
+ vehicle: vehicleDescriptorSchema,
297
+ })),
298
+ billing: z.looseObject({
299
+ charged_on: z.literal("creation"),
300
+ credits_charged: z.number(),
301
+ credits_remaining: z.number(),
302
+ renewal: z.looseObject({ window_seconds: z.number(), credits_per_window: z.number(), until: z.string() }).nullable().optional(),
303
+ }),
304
+ request_id: z.string(),
305
+ }),
306
+ search_vehicles: z.looseObject({
307
+ data: z.looseObject({ query: z.string(), results: z.array(vehicleMatchSchema) }),
308
+ }),
309
+ resolve_vehicle: z.looseObject({
310
+ data: z.looseObject({
311
+ /** Ready to pass straight to get_car_image. */
312
+ params: z.looseObject({
313
+ make: z.string(),
314
+ model: z.string(),
315
+ year: z.number().int(),
316
+ view: z.enum(VIEWS),
317
+ color: z.enum(COLORS),
318
+ }),
319
+ display: z.looseObject({ make_name: z.string(), model_name: z.string() }).optional(),
320
+ /** Other catalog vehicles that fit the phrase; ask when confidence is low. */
321
+ candidates: z.array(z.looseObject({
322
+ make_name: z.string().optional(),
323
+ make_slug: z.string().optional(),
324
+ model_name: z.string().optional(),
325
+ model_slug: z.string().optional(),
326
+ years: z.array(z.number().int()).optional(),
327
+ match_kind: z.string().optional(),
328
+ })),
329
+ confidence: z.union([z.enum(["high", "medium", "low"]), z.number()]),
330
+ image_path: z.string().optional(),
331
+ /** The attributes read out of the phrase; nulls are what it did not say. */
332
+ extracted: z.looseObject({}).optional(),
333
+ }),
334
+ /** The equivalent CLI invocation, for a human who wants to repeat it. */
335
+ cli: z.string(),
336
+ }),
337
+ list_image_options: z.looseObject({
338
+ data: z.looseObject({
339
+ views: z.array(z.looseObject({
340
+ id: z.enum(VIEWS),
341
+ label: z.string(),
342
+ yaw_degrees: z.number(),
343
+ description: z.string(),
344
+ aliases: z.array(z.string()),
345
+ })),
346
+ colors: z.array(z.looseObject({ name: z.enum(COLORS), hex: z.string() })),
347
+ sizes: z.looseObject({ presets: z.record(z.string(), z.number().int()), max: z.number().int() }),
348
+ formats: z.array(z.enum(FORMATS)),
349
+ pricing: pricingSchema,
350
+ catalog: z.looseObject({
351
+ makes: z.number().int(),
352
+ models: z.number().int(),
353
+ /** [first, last] model year in the catalog. */
354
+ years: z.tuple([z.number().int(), z.number().int()]),
355
+ sources: z.array(z.string()),
356
+ }),
357
+ }),
358
+ }),
359
+ get_account: z.looseObject({
360
+ data: z.looseObject({
361
+ credits: z.number(),
362
+ credits_usd: z.number(),
363
+ auto_reload: z.boolean(),
364
+ has_payment_method: z.boolean(),
365
+ pricing: pricingSchema,
366
+ key: z.looseObject({ id: z.string().optional(), scopes: z.array(z.string()) }).optional(),
367
+ }),
368
+ links: z.record(z.string(), z.string()).optional(),
369
+ request_id: z.string().optional(),
370
+ }),
371
+ rate_image: z.looseObject({
372
+ data: z.looseObject({
373
+ asset_id: z.string().nullable().optional(),
374
+ /** True when the render was marked for re-rendering. */
375
+ flagged: z.boolean(),
376
+ rating: z.number().int().optional(),
377
+ verdict: z.enum(["good", "bad"]).optional(),
378
+ }),
379
+ request_id: z.string(),
380
+ }),
381
+ list_requests: requestListResult,
382
+ request_vehicle: createdRequestResult,
383
+ request_feature: createdRequestResult,
384
+ get_request: z.looseObject({ data: requestDetailSchema, request_id: z.string() }),
385
+ upvote_request: requestResult,
386
+ comment_on_request: z.looseObject({ data: requestCommentSchema, request_id: z.string() }),
387
+ share_building: z.looseObject({
388
+ data: z.looseObject({ building: z.string(), updated_at: z.string() }),
389
+ request_id: z.string(),
390
+ }),
391
+ share_referral: z.looseObject({
392
+ data: z.looseObject({ source: z.enum(REFERRAL_SOURCES), detail: z.string().nullable(), updated_at: z.string() }),
393
+ request_id: z.string(),
394
+ }),
395
+ };
396
+ /**
397
+ * Normalizes a catalog hit into the one shape `search_vehicles` promises.
398
+ *
399
+ * The remote server reads the catalog library (camelCase) and the stdio one
400
+ * reads `GET /api/v1/vehicles?q=` (snake_case, and it omits `model_name`
401
+ * entirely on a make hit rather than sending null). Agents pass these straight
402
+ * to get_car_image, so both are mapped onto `make`/`model` with explicit nulls.
403
+ */
404
+ export function toVehicleMatch(source) {
405
+ return {
406
+ kind: source.kind,
407
+ make: source.makeName ?? source.make_name ?? "",
408
+ make_slug: source.makeSlug ?? source.make_slug ?? "",
409
+ model: source.modelName ?? source.model_name ?? null,
410
+ model_slug: source.modelSlug ?? source.model_slug ?? null,
411
+ vehicle_type: source.vehicleType ?? source.vehicle_type ?? null,
412
+ years: source.years ?? [],
413
+ match_kind: source.matchKind ?? source.match_kind ?? null,
414
+ };
415
+ }
416
+ function idsOf(values) {
417
+ if (!Array.isArray(values))
418
+ return [];
419
+ return values.map((value) => (typeof value === "string" ? value : String(value?.id ?? ""))).filter(Boolean);
420
+ }
421
+ /**
422
+ * Normalizes the options payload into the one shape `list_image_options`
423
+ * promises. `GET /api/v1/images/options` describes formats and catalog sources
424
+ * as objects and the year span as `{from, to}`; agents want bare ids and a
425
+ * `[from, to]` pair, so both that shape and an already-flat one are accepted.
426
+ * Anything else in the payload (`limits`, `sizes.source`) rides along untouched.
427
+ */
428
+ export function toImageOptions(data) {
429
+ const source = (data ?? {});
430
+ const catalog = (source.catalog ?? {});
431
+ const years = catalog.years;
432
+ return {
433
+ ...source,
434
+ formats: idsOf(source.formats),
435
+ catalog: {
436
+ ...catalog,
437
+ years: Array.isArray(years) ? [years[0], years[1]] : [years?.from ?? MIN_YEAR, years?.to ?? MIN_YEAR],
438
+ sources: idsOf(catalog.sources),
439
+ },
440
+ };
441
+ }
123
442
  export const MCP_TOOLS = {
124
443
  get_car_image: {
125
444
  name: "get_car_image",
126
445
  title: "Get car image",
127
446
  description: `Render or fetch a studio-quality, transparent-background image of a specific vehicle (make, model, year) from one of six camera angles in one of fifteen preset colors. Returns the image inline (PNG/WebP/JPG, up to ${MAX_DIMENSION} px) plus usage metadata (credits_charged, credits_remaining, source cache|generated, request_id). COSTS ${CREDITS_PER_IMAGE} CREDIT per call, cached or generated. Use resolve_vehicle first when the request is free text; use create_car_image_urls instead when the image must be embedded in a web page or document.`,
128
447
  inputSchema: MCP_TOOL_SCHEMAS.get_car_image,
448
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.get_car_image,
129
449
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
130
450
  },
131
451
  create_car_image_urls: {
132
452
  name: "create_car_image_urls",
133
453
  title: "Create signed car image URLs",
134
- description: "Create 1-50 signed, key-free delivery URLs that browsers, emails or documents can load directly (<img src>). Each URL costs 1 credit at creation; redemptions within the TTL are free and publicly cacheable. Returns id, url, expires_at, max_uses and the normalized vehicle for each image. Prefer this over get_car_image whenever the output is HTML, Markdown, a document or a website.",
454
+ description: "Create 1-50 signed, key-free delivery URLs that browsers, emails or documents can load directly (<img src>). Each URL costs 1 credit at creation; redemptions within the TTL are free and publicly cacheable. For email, CMS pages or documents that outlive a TTL, set renew: true with ttl_seconds 604800: the URL keeps serving past expiry at one more credit per opened week, for up to renew_days. Returns id, url, expires_at, max_uses, renews_until and the normalized vehicle for each image. Prefer this over get_car_image whenever the output is HTML, Markdown, a document or a website.",
135
455
  inputSchema: MCP_TOOL_SCHEMAS.create_car_image_urls,
456
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.create_car_image_urls,
136
457
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
137
458
  },
138
459
  search_vehicles: {
139
460
  name: "search_vehicles",
140
461
  title: "Search the vehicle catalog",
141
- description: "Free. Fuzzy-search the open vehicle catalog (1,145 makes, 14,841 models, 1990-2026) by make and/or model with typo tolerance. Returns canonical make/model names, available years and match kind, ready to pass to get_car_image. Use it to confirm a vehicle exists or to list a make's models before rendering.",
462
+ description: `Free. Fuzzy-search the open vehicle catalog (${CATALOG_MAKES} makes, ${CATALOG_MODELS} models, ${CATALOG_YEARS}) by make and/or model with typo tolerance. Returns canonical make/model names, available years and match kind, ready to pass to get_car_image. Use it to confirm a vehicle exists or to list a make's models before rendering.`,
142
463
  inputSchema: MCP_TOOL_SCHEMAS.search_vehicles,
464
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.search_vehicles,
143
465
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
144
466
  },
145
467
  resolve_vehicle: {
@@ -147,6 +469,7 @@ export const MCP_TOOLS = {
147
469
  title: "Resolve free text to image parameters",
148
470
  description: 'Free. Turn a natural-language request such as "red 2024 porsche 911 side view" into exact get_car_image parameters (make, model, year, view, color) plus alternative candidates and a 0-1 confidence. Call this before rendering when the user did not spell out the parameters; ask the user to choose when confidence is low or several candidates fit.',
149
471
  inputSchema: MCP_TOOL_SCHEMAS.resolve_vehicle,
472
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.resolve_vehicle,
150
473
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
151
474
  },
152
475
  list_image_options: {
@@ -154,6 +477,7 @@ export const MCP_TOOLS = {
154
477
  title: "List image options",
155
478
  description: "Free. List the supported camera views (with yaw angles and aliases), the fifteen preset colors with hex swatches, size presets, output formats, current pricing and catalog coverage. Use it when you need to validate or explain what can be requested.",
156
479
  inputSchema: MCP_TOOL_SCHEMAS.list_image_options,
480
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.list_image_options,
157
481
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
158
482
  },
159
483
  get_account: {
@@ -161,6 +485,7 @@ export const MCP_TOOLS = {
161
485
  title: "Get account and credits",
162
486
  description: "Free. Show the caller's remaining credits (and the dollar equivalent), auto-reload status, whether a payment method is on file, recent usage and the key's scopes. Check this before large batches; if credits are insufficient, ask a human to top up rather than buying credits yourself.",
163
487
  inputSchema: MCP_TOOL_SCHEMAS.get_account,
488
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.get_account,
164
489
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
165
490
  },
166
491
  rate_image: {
@@ -168,6 +493,7 @@ export const MCP_TOOLS = {
168
493
  title: "Rate a delivered image",
169
494
  description: "Free. Record quality feedback on a delivered image, either by the request_id returned with it or by vehicle (make, model, year, view, color). Provide verdict good|bad or rating 1-5 and an optional reason. Feedback drives re-renders and prompt improvements.",
170
495
  inputSchema: MCP_TOOL_SCHEMAS.rate_image,
496
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.rate_image,
171
497
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
172
498
  },
173
499
  describe_api: {
@@ -177,6 +503,70 @@ export const MCP_TOOLS = {
177
503
  inputSchema: MCP_TOOL_SCHEMAS.describe_api,
178
504
  annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
179
505
  },
506
+ list_requests: {
507
+ name: "list_requests",
508
+ title: "List vehicle and feature requests",
509
+ description: "Free, public. List what people have asked for: vehicles missing from the catalog and feature ideas, with vote and comment counts, status (open, planned, in_progress, done, declined) and a public page URL. Sort by votes or recency, filter by kind or status. Use it before request_vehicle or request_feature to find an existing request to upvote.",
510
+ inputSchema: MCP_TOOL_SCHEMAS.list_requests,
511
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.list_requests,
512
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
513
+ },
514
+ request_vehicle: {
515
+ name: "request_vehicle",
516
+ title: "Request a missing vehicle",
517
+ description: "Free. Ask for a vehicle that search_vehicles/resolve_vehicle cannot find. If an open request for the same make, model and year already exists it is upvoted instead and returned with deduplicated: true, so calling this is always safe. The requester's vote counts, and the human gets a short thank-you email with the request page.",
518
+ inputSchema: MCP_TOOL_SCHEMAS.request_vehicle,
519
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.request_vehicle,
520
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
521
+ },
522
+ request_feature: {
523
+ name: "request_feature",
524
+ title: "Request a feature",
525
+ description: "Free. File a feature request for the API (new views, formats, endpoints, anything). Not deduplicated: check list_requests first and upvote an existing one when it matches. Returns the request with its public page URL; the human gets a short thank-you email.",
526
+ inputSchema: MCP_TOOL_SCHEMAS.request_feature,
527
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.request_feature,
528
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
529
+ },
530
+ get_request: {
531
+ name: "get_request",
532
+ title: "Get a request",
533
+ description: "Free, public. One request with its full comment thread, vote count and status.",
534
+ inputSchema: MCP_TOOL_SCHEMAS.get_request,
535
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.get_request,
536
+ annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
537
+ },
538
+ upvote_request: {
539
+ name: "upvote_request",
540
+ title: "Upvote a request",
541
+ description: "Free. Add the caller's vote to a request (idempotent; voting twice changes nothing). Returns the updated request.",
542
+ inputSchema: MCP_TOOL_SCHEMAS.upvote_request,
543
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.upvote_request,
544
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
545
+ },
546
+ comment_on_request: {
547
+ name: "comment_on_request",
548
+ title: "Comment on a request",
549
+ description: "Free. Add a public comment to a request, e.g. the body style or use case that matters. Comments show the author's first name only.",
550
+ inputSchema: MCP_TOOL_SCHEMAS.comment_on_request,
551
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.comment_on_request,
552
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
553
+ },
554
+ share_building: {
555
+ name: "share_building",
556
+ title: "Tell us what you are building",
557
+ description: "Free. Record what the human is building with the API. Private to the Car Image team (never shown publicly); re-sharing overwrites the previous answer. Only call it with the human's own words and consent.",
558
+ inputSchema: MCP_TOOL_SCHEMAS.share_building,
559
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.share_building,
560
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
561
+ },
562
+ share_referral: {
563
+ name: "share_referral",
564
+ title: "Tell us how you found us",
565
+ description: "Free. Record where the human first heard about Car Image API (one of a fixed list of sources, plus optional detail). Private to the Car Image team; re-sharing overwrites. Only call it with the human's consent.",
566
+ inputSchema: MCP_TOOL_SCHEMAS.share_referral,
567
+ outputSchema: MCP_TOOL_OUTPUT_SCHEMAS.share_referral,
568
+ annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
569
+ },
180
570
  };
181
571
  export const MCP_TOOL_LIST = Object.values(MCP_TOOLS);
182
572
  /** Compact, hand-maintained mirror of /openapi.json for agents that cannot fetch it. */
@@ -190,7 +580,7 @@ export const API_REFERENCE = [
190
580
  params: [
191
581
  "make (required)",
192
582
  "model (required)",
193
- "year (required, 1990-2026)",
583
+ `year (required, ${CATALOG_YEARS})`,
194
584
  `view (${VIEWS.join("|")}; default front-3-4)`,
195
585
  `color (${COLORS.join("|")}; default silver)`,
196
586
  "size (thumb=256|small=512|medium=768|large=1024)",
@@ -209,20 +599,20 @@ export const API_REFERENCE = [
209
599
  summary: "Create 1-50 signed delivery URLs for browsers (no key needed to load them).",
210
600
  auth: "bearer",
211
601
  credits: "1 per URL, charged at creation; redemptions are free",
212
- params: ["images: [{make, model, year, view?, color?, width?, height?, format?}] (or a single image object)", "ttl_seconds (60-604800; default 3600)", "max_uses (0 = unlimited; default 0)"],
213
- returns: "201 {data:[{id,url,expires_at,max_uses,vehicle}], billing:{charged_on:'creation',credits_charged,credits_remaining}, request_id}",
602
+ params: ["images: [{make, model, year, view?, color?, width?, height?, format?}] (or a single image object)", "ttl_seconds (60-604800; default 3600)", "max_uses (0 = unlimited; default 0)", "renew (auto-renew past the TTL at 1 credit per opened window; requires max_uses 0)", "renew_days (1-365; default 365)"],
603
+ returns: "201 {data:[{id,url,expires_at,max_uses,renews_until,vehicle}], billing:{charged_on:'creation',credits_charged,credits_remaining,renewal?}, request_id}",
214
604
  errors: ["400 invalid body or batch > 50", "401", "402", "404 vehicle not in catalog", "429"],
215
- sdk: "client.createImageUrls([{ make, model, year }], { ttlSeconds, maxUses })",
216
- cli: "car-image url --make BMW --model M3 --year 2022 --ttl 86400",
605
+ sdk: "client.createImageUrls([{ make, model, year }], { ttlSeconds, maxUses, renew, renewDays })",
606
+ cli: "car-image url --make BMW --model M3 --year 2022 --ttl 86400 [--renew]",
217
607
  },
218
608
  {
219
609
  method: "GET",
220
610
  path: "/api/v1/delivery/{token}",
221
- summary: "Redeem a signed URL; public within its TTL and max_uses.",
611
+ summary: "Redeem a signed URL; public within its TTL and max_uses. An auto-renewing URL loaded after its TTL bills 1 credit for the new window on first load.",
222
612
  auth: "public",
223
- credits: "free",
224
- returns: "image bytes with public Cache-Control for the remaining lifetime",
225
- errors: ["404/410 expired, exhausted or invalid token"],
613
+ credits: "free within a paid window; 1 per new window of a renewable URL",
614
+ returns: "image bytes with public Cache-Control for the remaining window",
615
+ errors: ["403 expired, revoked or invalid token", "402 renewal unpaid (resumes once credits land)", "410 exhausted"],
226
616
  },
227
617
  {
228
618
  method: "GET",
@@ -267,6 +657,86 @@ export const API_REFERENCE = [
267
657
  sdk: "client.feedback({ requestId, verdict: 'good' })",
268
658
  cli: "car-image feedback --request-id <id> --good",
269
659
  },
660
+ {
661
+ method: "GET",
662
+ path: "/api/v1/requests",
663
+ summary: "Public board of vehicle and feature requests (votes, comments, status).",
664
+ auth: "public",
665
+ credits: "free",
666
+ params: ["kind (vehicle|feature)", "status (open|planned|in_progress|done|declined; default: all but declined)", "sort (top|new; default top)", "limit (1-100; default 25)", "cursor (from next_cursor)"],
667
+ returns: "{data:[{id,kind,title,body,vehicle:{make,model,year}|null,status,votes,comments,author,url,created_at,updated_at,viewer?:{voted}}], next_cursor, request_id}. `viewer` appears when a key is sent.",
668
+ sdk: "client.listRequests({ kind: 'vehicle', sort: 'top' })",
669
+ cli: "car-image request list --kind vehicle",
670
+ },
671
+ {
672
+ method: "POST",
673
+ path: "/api/v1/requests",
674
+ summary: "Request a missing vehicle or a feature.",
675
+ auth: "bearer",
676
+ credits: "free (scope account:read)",
677
+ params: ["kind: vehicle -> make, model (required), year?, body?", "kind: feature -> title (required), body?"],
678
+ returns: "201 {data: request, deduplicated:false} or 200 {data, deduplicated:true} when an open vehicle request for the same make/model/year already existed (it was upvoted instead)",
679
+ notes: "The creator's own vote counts. A new request sends the requester one thank-you email; dedup hits, upvotes and comments never email.",
680
+ sdk: "client.createRequest({ kind: 'vehicle', make: 'Rivian', model: 'R1T', year: 2024 })",
681
+ cli: "car-image request vehicle --make Rivian --model R1T --year 2024",
682
+ },
683
+ {
684
+ method: "GET",
685
+ path: "/api/v1/requests/{id}",
686
+ summary: "One request with its comment thread.",
687
+ auth: "public",
688
+ credits: "free",
689
+ returns: "{data:{...request, comment_count, comments:[{id,request_id,body,author,created_at}]}, request_id}",
690
+ errors: ["404 unknown request"],
691
+ sdk: "client.getRequest(id)",
692
+ cli: "car-image request show <id>",
693
+ },
694
+ {
695
+ method: "POST",
696
+ path: "/api/v1/requests/{id}/votes",
697
+ summary: "Upvote a request (idempotent). DELETE removes the vote.",
698
+ auth: "bearer",
699
+ credits: "free (scope account:read)",
700
+ returns: "200 {data: request with viewer.voted, request_id}",
701
+ errors: ["404 unknown request"],
702
+ sdk: "client.voteRequest(id) / client.unvoteRequest(id)",
703
+ cli: "car-image request upvote <id>",
704
+ },
705
+ {
706
+ method: "POST",
707
+ path: "/api/v1/requests/{id}/comments",
708
+ summary: "Comment on a request; GET lists the thread (public).",
709
+ auth: "bearer",
710
+ credits: "free (scope account:read)",
711
+ params: ["body (1-1000 chars)"],
712
+ returns: "201 {data:{id,request_id,body,author,created_at}, request_id}",
713
+ errors: ["400 empty or long body", "404 unknown request", "429 too many comments"],
714
+ sdk: "client.commentOnRequest(id, body)",
715
+ cli: "car-image request comment <id> \"…\"",
716
+ },
717
+ {
718
+ method: "POST",
719
+ path: "/api/v1/account/building",
720
+ summary: "Tell us what you are building (private; overwrites).",
721
+ auth: "bearer",
722
+ credits: "free (scope account:read)",
723
+ params: ["building (1-2000 chars)"],
724
+ returns: "200 {data:{building, updated_at}, request_id}",
725
+ notes: "Never returned by any public endpoint. First answer earns one thank-you email.",
726
+ sdk: "client.shareBuilding('A dealer site…')",
727
+ cli: "car-image about building \"A dealer site…\"",
728
+ },
729
+ {
730
+ method: "POST",
731
+ path: "/api/v1/account/referral",
732
+ summary: "Tell us how you found us (private; overwrites).",
733
+ auth: "bearer",
734
+ credits: "free (scope account:read)",
735
+ params: [`source (${REFERRAL_SOURCES.join("|")})`, "detail (optional, <= 500 chars)"],
736
+ returns: "200 {data:{source, detail, updated_at}, request_id}",
737
+ sdk: "client.shareReferral({ source: 'github' })",
738
+ cli: "car-image about referral github --detail \"the vehicle-db README\"",
739
+ },
270
740
  {
271
741
  method: "GET",
272
742
  path: "/api/v1/account",