@sudomock/mcp 3.0.0 → 3.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/dist/index.js CHANGED
@@ -3,8 +3,7 @@
3
3
  * SudoMock MCP Server
4
4
  *
5
5
  * Local stdio MCP server for Claude Desktop, Claude Code, Cursor, and VS Code.
6
- * Renders photorealistic product mockups from Photoshop PSD templates and
7
- * product photos via the SudoMock API.
6
+ * Renders photorealistic product mockups from Photoshop PSD templates via the SudoMock API.
8
7
  *
9
8
  * Auth: SUDOMOCK_API_KEY environment variable
10
9
  * Transport: stdio (local process)
@@ -173,7 +172,7 @@ export function formatJobAccepted(result) {
173
172
  };
174
173
  return JSON.stringify(summary, null, 2);
175
174
  }
176
- export const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed", "cancelled"]);
175
+ export const TERMINAL_JOB_STATUSES = new Set(["succeeded", "failed"]);
177
176
  /** GET /api/v1/jobs/{job_id} -- owner-scoped job status snapshot. */
178
177
  async function getJob(jobId, timeout = DEFAULT_TIMEOUT) {
179
178
  const result = (await apiRequest({
@@ -528,6 +527,7 @@ function publicAccount(result) {
528
527
  const envelope = asRecord(result);
529
528
  const data = asRecord(envelope.data ?? result);
530
529
  const account = asRecord(data.account);
530
+ const organization = asRecord(data.organization);
531
531
  const subscription = asRecord(data.subscription);
532
532
  const usage = asRecord(data.usage);
533
533
  const apiKey = asRecord(data.api_key);
@@ -540,6 +540,12 @@ function publicAccount(result) {
540
540
  name: account.name ?? null,
541
541
  created_at: account.created_at ?? null,
542
542
  },
543
+ // The plan, the credits and the balance below belong to this
544
+ // organization, not to the person: the API key is the organization's.
545
+ organization: {
546
+ id: organization.id ?? null,
547
+ name: organization.name ?? null,
548
+ },
543
549
  subscription: {
544
550
  plan: subscription.plan ?? null,
545
551
  tier: subscription.tier ?? null,
@@ -602,6 +608,57 @@ function publicWebhookAction(result) {
602
608
  status: data.status ?? null,
603
609
  };
604
610
  }
611
+ function publicWebhookBulkReplay(result) {
612
+ const envelope = asRecord(result);
613
+ const data = asRecord(envelope.data ?? result);
614
+ return {
615
+ success: envelope.success !== false,
616
+ status: data.status ?? null,
617
+ count: data.count ?? null,
618
+ };
619
+ }
620
+ function publicFontList(result) {
621
+ const envelope = asRecord(result);
622
+ const rows = Array.isArray(envelope.data) ? envelope.data : [];
623
+ const pagination = asRecord(envelope.pagination);
624
+ return {
625
+ success: envelope.success !== false,
626
+ data: rows.map((row) => {
627
+ const font = asRecord(row);
628
+ return {
629
+ uuid: font.uuid ?? null,
630
+ family: font.family ?? null,
631
+ subfamily: font.subfamily ?? null,
632
+ postscript_name: font.postscript_name ?? null,
633
+ category: font.category ?? null,
634
+ license: font.license ?? null,
635
+ is_premium: font.is_premium === true,
636
+ is_system: font.is_system === true,
637
+ preview_url: font.preview_url ?? null,
638
+ file_url: font.file_url ?? null,
639
+ created_at: font.created_at ?? null,
640
+ };
641
+ }),
642
+ pagination: {
643
+ page: pagination.page ?? null,
644
+ per_page: pagination.per_page ?? null,
645
+ total: pagination.total ?? rows.length,
646
+ },
647
+ };
648
+ }
649
+ function publicUploadTarget(result) {
650
+ const envelope = asRecord(result);
651
+ const data = asRecord(envelope.data ?? result);
652
+ return {
653
+ success: envelope.success !== false,
654
+ data: {
655
+ upload_url: data.upload_url ?? null,
656
+ file_url: data.file_url ?? null,
657
+ method: data.method ?? null,
658
+ expires_in: data.expires_in ?? null,
659
+ },
660
+ };
661
+ }
605
662
  /**
606
663
  * A job is done when its status is terminal. The poll response (GET
607
664
  * /api/v1/jobs/{job_id}) always reports the job state in the `status` field.
@@ -647,10 +704,20 @@ function withToolLog(tool, cb) {
647
704
  }
648
705
  };
649
706
  }
707
+ /**
708
+ * What each tool was registered with, keyed by the name it was registered
709
+ * under. Replaying an entry under a second name publishes the same tool twice
710
+ * without a second copy of its handler or its input schema, which is how the
711
+ * family spellings at the bottom of this file are added.
712
+ */
713
+ const registrations = new Map();
650
714
  /**
651
715
  * Wraps every tool callback registered through `tool()` / `registerTool()` so
652
- * each call writes one log line. Both SDK methods take the callback as their
653
- * last argument; the registration call sites below stay untouched and typed.
716
+ * each call writes one log line, and records the registration on the way past.
717
+ * Both SDK methods take the callback as their last argument; the registration
718
+ * call sites below stay untouched and typed. The copy is taken before the
719
+ * callback is wrapped, so a replay logs under the name it is replayed as
720
+ * rather than under the name it was first registered with.
654
721
  */
655
722
  function instrumentToolCalls(target) {
656
723
  for (const method of ["tool", "registerTool"]) {
@@ -658,6 +725,7 @@ function instrumentToolCalls(target) {
658
725
  const wrapped = (...args) => {
659
726
  const last = args.length - 1;
660
727
  if (typeof args[0] === "string" && typeof args[last] === "function") {
728
+ registrations.set(args[0], { method, args: [...args] });
661
729
  args[last] = withToolLog(args[0], args[last]);
662
730
  }
663
731
  return original.apply(target, args);
@@ -674,9 +742,9 @@ const server = new McpServer({
674
742
  });
675
743
  instrumentToolCalls(server);
676
744
  // ---------------------------------------------------------------------------
677
- // Tool 1: list_psd_mockups
745
+ // Tool 1: list_mockups
678
746
  // ---------------------------------------------------------------------------
679
- server.tool("list_psd_mockups", "List your uploaded PSD mockup templates with UUIDs, names, and thumbnails. Use returned UUIDs with render_psd_mockup or get_psd_mockup.", {
747
+ server.tool("list_mockups", "List your uploaded mockup templates with UUIDs, names, and thumbnails. Use returned UUIDs with render_mockup or get_mockup_details.", {
680
748
  limit: z.number().min(1).max(100).default(20).describe("Results per page (1-100, default 20)"),
681
749
  offset: z.number().min(0).default(0).describe("Pagination offset (default 0)"),
682
750
  name: z.string().optional().describe("Filter by name (case-insensitive substring match)"),
@@ -687,7 +755,7 @@ server.tool("list_psd_mockups", "List your uploaded PSD mockup templates with UU
687
755
  }, async ({ limit, offset, name, created_after, created_before, sort_by, sort_order }) => {
688
756
  const result = await apiRequest({
689
757
  method: "GET",
690
- path: "/api/v1/psd-mockups",
758
+ path: "/api/v1/mockups",
691
759
  params: { limit, offset, name, created_after, created_before, sort: sort_by, order: sort_order },
692
760
  });
693
761
  return {
@@ -698,14 +766,14 @@ server.tool("list_psd_mockups", "List your uploaded PSD mockup templates with UU
698
766
  };
699
767
  });
700
768
  // ---------------------------------------------------------------------------
701
- // Tool 2: get_psd_mockup
769
+ // Tool 2: get_mockup_details
702
770
  // ---------------------------------------------------------------------------
703
- server.tool("get_psd_mockup", "Get full details of a PSD mockup: smart object UUIDs, layer names, dimensions, positions, blend modes, editable text layers, and thumbnail URLs.", {
704
- mockup_uuid: z.string().describe("The UUID of the PSD mockup to inspect"),
771
+ server.tool("get_mockup_details", "Get full details of a mockup: smart object UUIDs, layer names, dimensions, positions, blend modes, and thumbnail URLs.", {
772
+ mockup_uuid: z.string().describe("The UUID of the mockup to inspect"),
705
773
  }, async ({ mockup_uuid }) => {
706
774
  const result = await apiRequest({
707
775
  method: "GET",
708
- path: `/api/v1/psd-mockups/${mockup_uuid}`,
776
+ path: `/api/v1/mockups/${mockup_uuid}`,
709
777
  });
710
778
  return {
711
779
  content: [{
@@ -715,15 +783,15 @@ server.tool("get_psd_mockup", "Get full details of a PSD mockup: smart object UU
715
783
  };
716
784
  });
717
785
  // ---------------------------------------------------------------------------
718
- // Tool 3: update_psd_mockup
786
+ // Tool 3: update_mockup
719
787
  // ---------------------------------------------------------------------------
720
- server.tool("update_psd_mockup", "Rename a PSD mockup template.", {
721
- mockup_uuid: z.string().describe("The UUID of the PSD mockup to rename"),
788
+ server.tool("update_mockup", "Rename a mockup template.", {
789
+ mockup_uuid: z.string().describe("The UUID of the mockup to rename"),
722
790
  name: z.string().describe("New display name for the mockup"),
723
791
  }, async ({ mockup_uuid, name }) => {
724
792
  const result = await apiRequest({
725
793
  method: "PATCH",
726
- path: `/api/v1/psd-mockups/${mockup_uuid}`,
794
+ path: `/api/v1/mockups/${mockup_uuid}`,
727
795
  body: { name },
728
796
  });
729
797
  return {
@@ -734,19 +802,19 @@ server.tool("update_psd_mockup", "Rename a PSD mockup template.", {
734
802
  };
735
803
  });
736
804
  // ---------------------------------------------------------------------------
737
- // Tool 4: delete_psd_mockup
805
+ // Tool 4: delete_mockup
738
806
  // ---------------------------------------------------------------------------
739
- server.tool("delete_psd_mockup", "Permanently delete a PSD mockup template. Cannot be undone.", {
740
- mockup_uuid: z.string().describe("The UUID of the PSD mockup to delete"),
807
+ server.tool("delete_mockup", "Permanently delete a mockup template. Cannot be undone.", {
808
+ mockup_uuid: z.string().describe("The UUID of the mockup to delete"),
741
809
  }, async ({ mockup_uuid }) => {
742
810
  await apiRequest({
743
811
  method: "DELETE",
744
- path: `/api/v1/psd-mockups/${mockup_uuid}`,
812
+ path: `/api/v1/mockups/${mockup_uuid}`,
745
813
  });
746
814
  return { content: [{ type: "text", text: `Mockup ${mockup_uuid} deleted successfully.` }] };
747
815
  });
748
816
  // ---------------------------------------------------------------------------
749
- // Tool 5: render_psd_mockup
817
+ // Tool 5: render_mockup
750
818
  // ---------------------------------------------------------------------------
751
819
  const smartObjectInputSchema = z
752
820
  .object({
@@ -811,7 +879,7 @@ const textLayerInputSchema = z
811
879
  uuid: z
812
880
  .string()
813
881
  .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/)
814
- .describe("UUID from get_psd_mockup text_layers"),
882
+ .describe("UUID from get_mockup_details text_layers"),
815
883
  text: z.string().min(1).max(500).optional().describe("Replacement text for a single-style layer"),
816
884
  segments: z
817
885
  .array(z.object({
@@ -858,8 +926,8 @@ const textLayerInputSchema = z
858
926
  ctx.addIssue({ code: "custom", message: "Segment indexes must be unique" });
859
927
  }
860
928
  });
861
- server.tool("render_psd_mockup", "Render a PSD mockup with artwork, editable text, or both. Supports one or multiple smart objects and preserves the template's authored appearance. Returns the rendered image URL. Costs 1 credit. Use list_psd_mockups and get_psd_mockup to find target UUIDs.", {
862
- mockup_uuid: z.string().describe("UUID of the PSD mockup template (from list_psd_mockups)"),
929
+ server.tool("render_mockup", "Render a PSD mockup with artwork, editable text, or both. Supports one or multiple smart objects and preserves the template's authored appearance. Returns the rendered image URL. Costs 1 credit. Use list_mockups and get_mockup_details to find target UUIDs.", {
930
+ mockup_uuid: z.string().describe("UUID of the mockup template (from list_mockups)"),
863
931
  smart_object_uuid: z.string().optional().describe("UUID of one smart object layer. Provide with artwork_url, or use smart_objects for one or more entries."),
864
932
  artwork_url: z.string().optional().describe("Public artwork URL for smart_object_uuid. Provide both singular fields, or use smart_objects."),
865
933
  smart_objects: z
@@ -872,7 +940,7 @@ server.tool("render_psd_mockup", "Render a PSD mockup with artwork, editable tex
872
940
  .min(1)
873
941
  .max(50)
874
942
  .optional()
875
- .describe("Editable text overrides from get_psd_mockup. Each entry needs exactly one of text or segments. May be used alone or with smart objects."),
943
+ .describe("Editable text overrides from get_mockup_details. Each entry needs exactly one of text or segments. May be used alone or with smart objects."),
876
944
  fit: z.enum(["fill", "contain", "cover"]).default("fill").describe("How singular artwork_url fills its smart object area"),
877
945
  image_format: z.enum(["webp", "png", "jpg"]).default("webp").describe("Output format"),
878
946
  image_size: z.number().min(100).max(10000).default(2048).describe("Output width in pixels (default 2048)"),
@@ -1002,9 +1070,40 @@ server.tool("remove_background", "Remove the background from any image and retur
1002
1070
  };
1003
1071
  });
1004
1072
  // ---------------------------------------------------------------------------
1005
- // Tool: create_photo_mockup
1073
+ // Tool: list_fonts
1006
1074
  // ---------------------------------------------------------------------------
1007
- server.tool("create_photo_mockup", "Create a reusable photo mockup from a public image URL of a product. Returns the mockup_id and its render targets synchronously. Costs 25 credits. If the image is unsuitable, the 25 credits are refunded automatically. Set is_async=true to queue instead and receive a job_id to poll with get_job or wait_for_job. Use the dashboard for visual fine-tuning.", {
1075
+ server.tool("list_fonts", "List fonts available for text layers: the shared system catalog plus any fonts you have uploaded. Filter by family name (search), by category (serif, sans-serif, display), or by scope ('all', 'system', or 'custom' for your uploads only); results are paginated (per_page up to 100). Use a font's uuid or postscript_name as the font of a text_layers entry when rendering with render_mockup. Costs 0 credits.", {
1076
+ search: z.string().optional().describe("Filter by family name (case-insensitive contains), e.g. 'sans'"),
1077
+ category: z.string().optional().describe("Filter by category, e.g. 'serif', 'sans-serif', 'display'"),
1078
+ scope: z
1079
+ .enum(["all", "system", "custom"])
1080
+ .default("all")
1081
+ .describe("Which fonts to return: 'all' (system fonts plus your uploads, the default), 'system', or 'custom' (your uploads only)"),
1082
+ page: z.number().int().min(1).default(1).describe("Page number (1-based, default 1)"),
1083
+ per_page: z.number().int().min(1).max(100).default(50).describe("Results per page (1-100, default 50)"),
1084
+ }, async ({ search, category, scope, page, per_page }) => {
1085
+ const result = await apiRequest({
1086
+ method: "GET",
1087
+ path: "/api/v1/fonts",
1088
+ params: {
1089
+ page,
1090
+ per_page,
1091
+ scope,
1092
+ search: search || undefined,
1093
+ category: category || undefined,
1094
+ },
1095
+ });
1096
+ return {
1097
+ content: [{
1098
+ type: "text",
1099
+ text: JSON.stringify(publicFontList(result), null, 2),
1100
+ }],
1101
+ };
1102
+ });
1103
+ // ---------------------------------------------------------------------------
1104
+ // Tool: create_2d_mockup
1105
+ // ---------------------------------------------------------------------------
1106
+ server.tool("create_2d_mockup", "Create a reusable photo mockup from a public image URL. Returns the mockup ID and public render targets synchronously. Costs 25 credits. If the image is unsuitable, the 25 credits are refunded automatically. Set is_async=true to queue instead and receive a job_id to poll with get_job or wait_for_job. Use the dashboard for visual fine-tuning.", {
1008
1107
  source_url: z.string().describe("Public HTTPS URL of the product image"),
1009
1108
  name: z.string().optional().describe("Optional display name for the photo mockup"),
1010
1109
  idempotency_key: z.string().min(1).max(255).optional().describe("Optional retry-stable key for this create request"),
@@ -1020,7 +1119,7 @@ server.tool("create_photo_mockup", "Create a reusable photo mockup from a public
1020
1119
  body.is_async = true;
1021
1120
  const result = (await apiRequest({
1022
1121
  method: "POST",
1023
- path: "/api/v1/photo-mockups",
1122
+ path: "/api/v1/sudoai/2d-mockups",
1024
1123
  body,
1025
1124
  headers: { "Idempotency-Key": idempotency_key ?? randomUUID() },
1026
1125
  }));
@@ -1054,16 +1153,15 @@ server.tool("create_photo_mockup", "Create a reusable photo mockup from a public
1054
1153
  return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
1055
1154
  });
1056
1155
  // ---------------------------------------------------------------------------
1057
- // Tool: render_photo_mockup
1156
+ // 2D render: one tool per kind of target
1058
1157
  // ---------------------------------------------------------------------------
1059
- // A render names one target and nothing else: exactly one of print_area_uuid
1060
- // or surface_uuid. A product surface is sized by a coverage percentage, a
1061
- // print area by a fit, and either by an exact width + height. One tool holds
1062
- // both dials, the same shape the hosted server exposes, so the dial that
1063
- // belongs to the other kind of target is refused here by name instead of
1064
- // being a field the caller could never reach.
1065
- const PHOTO_RENDER_SHARED = {
1066
- mockup_id: z.string().describe("UUID of the photo mockup (mockup_id from list_photo_mockups, get_photo_mockup or create_photo_mockup)."),
1158
+ // A render names one target and nothing else. A product surface takes a
1159
+ // coverage percentage; a print area takes a fit or an explicit box. Two tools
1160
+ // rather than one, because those are two different dials and a single tool
1161
+ // could only describe in prose what the shape can state outright: whichever
1162
+ // tool the caller reaches for, every field on it applies.
1163
+ const TWO_D_SHARED = {
1164
+ mockup_uuid: z.string().describe("UUID of the photo mockup template (from list_2d_mockups, returned as mockup_id)."),
1067
1165
  artwork_url: z.string().describe("Public URL of the artwork image (PNG/JPG/WebP) to place on the mockup"),
1068
1166
  remove_background: z.boolean().default(false).describe("Remove the artwork's background before placing it. Adds 25 credits per artwork."),
1069
1167
  opacity: z.number().min(0).max(100).default(100).describe("Artwork opacity percentage (0-100)"),
@@ -1107,82 +1205,74 @@ const PHOTO_RENDER_SHARED = {
1107
1205
  is_async: z
1108
1206
  .boolean()
1109
1207
  .default(false)
1110
- .describe("Queue the render instead of waiting. When true the API returns 202 with a job_id immediately -- poll with get_job, or call wait_for_job to block until it finishes and hands back result_url. Default false returns print_files + render_uuid synchronously (200). Use for long renders or when running many in parallel."),
1208
+ .describe("Queue the render instead of waiting. When true the API returns 202 with a job_id immediately; poll with get_job, or call wait_for_job to block until it finishes and hands back result_url. Default false returns print_files + render_uuid synchronously (200). Use for long renders or when running many in parallel."),
1111
1209
  };
1112
- // The sentences a refused option is answered with.
1210
+ // The option a caller can write on one of these two tools but not the other,
1211
+ // and the sentence that sends them to the one that works.
1113
1212
  //
1114
- // The API answers each of these with a 422, and refusing them here as well is
1115
- // deliberate: a client that can put a shape on the wire the API is guaranteed
1116
- // to reject has spent the caller's round trip telling them something it
1117
- // already knew. The consumer on this side is a language model: an option it
1118
- // wrote disappearing in silence teaches it nothing and it writes the same
1119
- // option again, while one named sentence teaches it in a single turn. The
1120
- // wording is the API's own, so the caller reads the same reason whichever
1213
+ // An argument a tool never declared is dropped before the handler ever sees it,
1214
+ // so `coverage` on a print area, `fit` on a surface, and the retired `scale` on
1215
+ // either used to come back as a successful render with the caller's option
1216
+ // gone and nothing said about it. The API answers all three with a 422. The
1217
+ // consumer on this side is a language model: an option it wrote disappearing in
1218
+ // silence teaches it nothing and it writes the same option again, while one
1219
+ // named sentence teaches it in a single turn.
1220
+ //
1221
+ // The wording is the API's own, so the caller reads the same reason whichever
1121
1222
  // side answers first.
1122
- const ONE_TARGET = "Provide exactly one of print_area_uuid or surface_uuid";
1123
- const FIT_ON_SURFACE = "A surface covers the whole product, so fit has nothing to fit against. Send coverage to choose how much of the surface the artwork spans, or width and height to give it an exact size, or render a print area instead.";
1124
- const COVERAGE_ON_PRINT_AREA = "A print area is sized by fit, or by an explicit width and height, so coverage has no meaning on one. Send fit or a width and height, or render the product surface instead.";
1125
1223
  const RETIRED_SCALE = "scale has been retired and nothing replaces the name. Send width and height together to draw the artwork at an exact size; a single multiplier could not express two independent axes.";
1224
+ const TWO_D_REFUSED_OPTIONS = {
1225
+ surface: {
1226
+ fit: "A surface covers the whole product, so fit has nothing to fit against. Send coverage to choose how much of the surface the artwork spans, or width and height to give it an exact size, or render a print area instead.",
1227
+ scale: RETIRED_SCALE,
1228
+ },
1229
+ print_area: {
1230
+ coverage: "A print area is sized by fit, or by an explicit width and height, so coverage has no meaning on one. Send fit or a width and height, or render the product surface instead.",
1231
+ scale: RETIRED_SCALE,
1232
+ },
1233
+ };
1126
1234
  /**
1127
- * The tool's input. Strict on purpose: an argument a tool never declared is
1128
- * dropped before the handler ever sees it, so the retired `scale` used to come
1129
- * back as a successful render with the caller's option gone and nothing said
1130
- * about it. A name we never published is either a typo or an option the
1131
- * caller believes exists, and both are worth one line of reply.
1235
+ * Build the arguments one 2D render tool accepts, and refuse the rest by name.
1236
+ *
1237
+ * Strict on purpose, and only on these two tools: the pair exists precisely
1238
+ * because the sizing options are split between them, so an option landing on
1239
+ * the wrong one of the two is the mistake most worth catching. Refusing is
1240
+ * keyed on the argument being written rather than on the value it holds, so an
1241
+ * explicitly written null is caught as well: writing it is the caller naming
1242
+ * the option.
1243
+ *
1244
+ * An option with no sentence of its own still gets refused rather than dropped.
1245
+ * A name we never published is either a typo or an option the caller believes
1246
+ * exists, and both are worth one line of reply.
1132
1247
  */
1133
- const renderPhotoMockupInput = z.strictObject({
1134
- ...PHOTO_RENDER_SHARED,
1135
- print_area_uuid: z
1136
- .string()
1137
- .optional()
1138
- .describe("UUID of a saved print area from get_photo_mockup's print_areas[] (its print_area_id): a bounded zone somebody drew on the product, such as a chest logo. Omit when surface_uuid is used."),
1139
- surface_uuid: z
1140
- .string()
1141
- .optional()
1142
- .describe("UUID of a product surface from get_photo_mockup's surfaces[]: a whole printable product, for an all-over print. Omit when print_area_uuid is used."),
1143
- coverage: z
1144
- .number()
1145
- .min(10)
1146
- .max(100)
1147
- .optional()
1148
- .describe("How much of the surface the artwork spans, as a percentage (10-100). Belongs to surface_uuid; sending it with print_area_uuid is refused. Omit to span the whole surface, which is what an all-over print usually wants. Send width and height instead to give the artwork an exact size."),
1149
- fit: z
1150
- .enum(["contain", "fill", "cover"])
1151
- .optional()
1152
- .describe("How the artwork meets the print area, which it always fills edge to edge: 'contain' keeps the proportions and fits inside (the default), 'fill' stretches to the edges, 'cover' fills and crops the overflow. Belongs to print_area_uuid; sending it with surface_uuid is refused. Leave it out to get 'contain'. To sit inside the area with room around it, send width and height instead."),
1153
- // A percentage cannot express a box whose proportions differ from the
1154
- // surface's, which is exactly what an artwork resized on a canvas is, so
1155
- // the exact box belongs to both kinds of target.
1156
- width: z
1157
- .number()
1158
- .min(1)
1159
- .max(30000)
1160
- .optional()
1161
- .describe("Artwork width in pixels, drawn at that exact size instead of by coverage or fit. Send together with height, and without coverage or fit. Width and height are independent, so any aspect ratio is allowed - stretching on one axis only is a supported placement."),
1162
- height: z
1163
- .number()
1164
- .min(1)
1165
- .max(30000)
1166
- .optional()
1167
- .describe("Artwork height in pixels. Send together with width. Sending only one of the two is rejected rather than silently completed, so the aspect ratio is never guessed for you."),
1168
- }, {
1169
- error: (issue) => {
1170
- if (issue.code !== "unrecognized_keys")
1171
- return undefined;
1172
- return issue.keys
1173
- .map((key) => key === "scale"
1174
- ? RETIRED_SCALE
1175
- : `${key} is not an option on this tool. Send only the options it lists.`)
1176
- .join(" ");
1177
- },
1178
- });
1248
+ function twoDRenderInput(kind, shape) {
1249
+ const refused = TWO_D_REFUSED_OPTIONS[kind];
1250
+ return z.strictObject(shape, {
1251
+ error: (issue) => {
1252
+ if (issue.code !== "unrecognized_keys")
1253
+ return undefined;
1254
+ return issue.keys
1255
+ .map((key) => refused[key] ??
1256
+ `${key} is not an option on this tool. Send only the options it lists, and read them from the tool's own arguments rather than from another tool's.`)
1257
+ .join(" ");
1258
+ },
1259
+ });
1260
+ }
1179
1261
  /**
1180
1262
  * Refuse a placement that answers the sizing question more than once, or half.
1181
1263
  *
1182
- * A default invents a value the caller never typed and quietly changes the
1183
- * render when it drifts from the renderer's; a refusal invents nothing, and if
1184
- * it ever drifts it drifts into refusing out loud, with the API still refusing
1185
- * behind it.
1264
+ * The API refuses these too, and refusing them here as well is deliberate. A
1265
+ * client that can put a shape on the wire the API is guaranteed to reject has
1266
+ * spent the caller's round trip telling them something it already knew.
1267
+ *
1268
+ * This is not the thing that was taken out of here. A default invents a value
1269
+ * the caller never typed and quietly changes the render when it drifts from
1270
+ * the renderer's; a refusal invents nothing, and if it ever drifts it drifts
1271
+ * into refusing out loud, with the API still refusing behind it. It also
1272
+ * cannot drift unnoticed: the contract test reads the shared placement wire
1273
+ * fixture and asserts every shape listed there as rejected is still
1274
+ * unreachable from every tool, and the wording below is the wording the API
1275
+ * answers with, so a caller reads the same reason from either side.
1186
1276
  *
1187
1277
  * `relativeSizing` is whichever proportional option belongs to this kind of
1188
1278
  * target -- `coverage` on a surface, `fit` on a print area.
@@ -1200,15 +1290,16 @@ function assertOneSizingAnswer(placement, relativeSizing) {
1200
1290
  /**
1201
1291
  * Send one render and hand back what the caller can act on.
1202
1292
  *
1203
- * `target` carries the address field, `placement` the sizing dials that
1204
- * belong to that kind of target. Neither is inspected here: the handler has
1205
- * already settled which target this is and which dials it may carry.
1293
+ * `target` carries the address field, `placement` the dials that belong to that
1294
+ * kind of target. Neither is inspected here: the dials a caller may turn are
1295
+ * settled by which tool they called, so there is nothing left to check.
1206
1296
  */
1207
- async function renderPhotoMockup(args, target, placement) {
1208
- // Anchoring belongs to both kinds of target; only sizing is split. What the
1209
- // caller did not name is absent, and a placement nobody touched is absent
1210
- // entirely rather than sent as an empty object -- an empty object still
1211
- // claims the caller reached for placement.
1297
+ async function renderTwoD(args, target, placement) {
1298
+ // Anchoring belongs to both kinds of target; only sizing is split, and the
1299
+ // caller's tool already settled which sizing options exist. What the caller
1300
+ // did not name is absent, and a placement nobody touched is absent entirely
1301
+ // rather than sent as an empty object -- an empty object still claims the
1302
+ // caller reached for placement.
1212
1303
  const sent = {
1213
1304
  ...(args.position === undefined ? {} : { position: args.position }),
1214
1305
  ...(args.offset_x === undefined ? {} : { offset_x: args.offset_x }),
@@ -1241,13 +1332,13 @@ async function renderPhotoMockup(args, target, placement) {
1241
1332
  body.is_async = true;
1242
1333
  const result = await apiRequest({
1243
1334
  method: "POST",
1244
- path: `/api/v1/photo-mockups/${args.mockup_id}/render`,
1335
+ path: `/api/v1/sudoai/2d-mockups/${args.mockup_uuid}/render`,
1245
1336
  body,
1246
1337
  timeout: RENDER_TIMEOUT,
1247
1338
  });
1248
- // is_async=true -> 202 + job_id (kind "photo_mockup_render" on this path);
1249
- // hand back the poll contract. Reuse get_job / wait_for_job to reach the
1250
- // terminal job (result_url).
1339
+ // is_async=true -> 202 + job_id (kind "2d_render" on this path; the family
1340
+ // spelling is "photo_mockup_render"); hand back the poll contract.
1341
+ // Reuse get_job / wait_for_job to reach the terminal job (result_url).
1251
1342
  if (args.is_async) {
1252
1343
  return { content: [{ type: "text", text: formatJobAccepted(result) }] };
1253
1344
  }
@@ -1267,23 +1358,71 @@ async function renderPhotoMockup(args, target, placement) {
1267
1358
  };
1268
1359
  return { content: [{ type: "text", text: JSON.stringify(rendered, null, 2) }] };
1269
1360
  }
1270
- server.registerTool("render_photo_mockup", {
1271
- description: "Render artwork onto a saved photo mockup. Name exactly one target: a print_area_uuid (a bounded zone somebody drew on the product, such as a chest logo; sized by fit or by width + height) or a surface_uuid (a whole printable product, for an all-over print; sized by coverage or by width + height). Read both from get_photo_mockup. Returns print_files (each with an export_path) and a render_uuid. Costs 5 credits. Use the dashboard for visual fine-tuning.",
1272
- inputSchema: renderPhotoMockupInput,
1361
+ // ---------------------------------------------------------------------------
1362
+ // Tool: render_2d_surface
1363
+ // ---------------------------------------------------------------------------
1364
+ server.registerTool("render_2d_surface", {
1365
+ description: "Render artwork across a whole product surface -- an all-over print. Every printable product in the photo is a surface with its own surface_uuid, listed by get_2d_mockup. Returns print_files (each with an export_path) and a render_uuid. Costs 5 credits. Use the dashboard for visual fine-tuning.",
1366
+ inputSchema: twoDRenderInput("surface", {
1367
+ ...TWO_D_SHARED,
1368
+ surface_uuid: z.string().describe("UUID of a product surface from get_2d_mockup's surfaces[]."),
1369
+ coverage: z
1370
+ .number()
1371
+ .min(10)
1372
+ .max(100)
1373
+ .optional()
1374
+ .describe("How much of the surface the artwork spans, as a percentage (10-100). Omit to span the whole surface, which is what an all-over print usually wants. Send width and height instead to give the artwork an exact size."),
1375
+ // A percentage cannot express a box whose proportions differ from the
1376
+ // surface's, which is exactly what an artwork resized on a canvas is.
1377
+ width: z
1378
+ .number()
1379
+ .min(1)
1380
+ .max(30000)
1381
+ .optional()
1382
+ .describe("Artwork width in pixels, drawn at that exact size rather than as a percentage of the surface. Send together with height, and without coverage."),
1383
+ height: z
1384
+ .number()
1385
+ .min(1)
1386
+ .max(30000)
1387
+ .optional()
1388
+ .describe("Artwork height in pixels. Send together with width, and without coverage. The two axes are independent, so any aspect ratio is allowed."),
1389
+ }),
1273
1390
  }, async (args) => {
1274
- const hasPrintArea = args.print_area_uuid !== undefined;
1275
- const hasSurface = args.surface_uuid !== undefined;
1276
- if (hasPrintArea === hasSurface)
1277
- throw new Error(ONE_TARGET);
1278
- // The dial of the other kind of target is refused by name. Refusing is
1279
- // keyed on the argument being written rather than on the value it holds:
1280
- // writing it is the caller naming the option.
1281
- if (hasSurface && args.fit !== undefined)
1282
- throw new Error(FIT_ON_SURFACE);
1283
- if (hasPrintArea && args.coverage !== undefined)
1284
- throw new Error(COVERAGE_ON_PRINT_AREA);
1285
1391
  const sizing = {
1286
1392
  ...(args.coverage === undefined ? {} : { coverage: args.coverage }),
1393
+ ...(args.width === undefined ? {} : { width: args.width }),
1394
+ ...(args.height === undefined ? {} : { height: args.height }),
1395
+ };
1396
+ assertOneSizingAnswer(sizing, "coverage");
1397
+ return renderTwoD(args, { surface_uuid: args.surface_uuid }, sizing);
1398
+ });
1399
+ // ---------------------------------------------------------------------------
1400
+ // Tool: render_2d_print_area
1401
+ // ---------------------------------------------------------------------------
1402
+ server.registerTool("render_2d_print_area", {
1403
+ description: "Render artwork onto one saved print area -- a bounded zone somebody drew on the product, such as a chest logo. Read the print_area_id values from get_2d_mockup. Returns print_files (each with an export_path) and a render_uuid. Costs 5 credits. Use the dashboard for visual fine-tuning.",
1404
+ inputSchema: twoDRenderInput("print_area", {
1405
+ ...TWO_D_SHARED,
1406
+ print_area_uuid: z.string().describe("UUID of a saved print area from get_2d_mockup's print_areas[] (its print_area_id)."),
1407
+ fit: z
1408
+ .enum(["contain", "fill", "cover"])
1409
+ .optional()
1410
+ .describe("How the artwork meets the print area, which it always fills edge to edge: 'contain' keeps the proportions and fits inside (the default), 'fill' stretches to the edges, 'cover' fills and crops the overflow. Leave it out to get 'contain'. To sit inside the area with room around it, send width and height instead."),
1411
+ width: z
1412
+ .number()
1413
+ .min(1)
1414
+ .max(30000)
1415
+ .optional()
1416
+ .describe("Artwork width in print-area pixels. Send together with height, and without fit. Width and height are independent, so any aspect ratio is allowed - stretching on one axis only is a supported placement."),
1417
+ height: z
1418
+ .number()
1419
+ .min(1)
1420
+ .max(30000)
1421
+ .optional()
1422
+ .describe("Artwork height in print-area pixels. Send together with width. Sending only one of the two is rejected rather than silently completed, so the aspect ratio is never guessed for you."),
1423
+ }),
1424
+ }, async (args) => {
1425
+ const sizing = {
1287
1426
  ...(args.fit === undefined ? {} : { fit: args.fit }),
1288
1427
  ...(args.width === undefined ? {} : { width: args.width }),
1289
1428
  ...(args.height === undefined ? {} : { height: args.height }),
@@ -1292,20 +1431,20 @@ server.registerTool("render_photo_mockup", {
1292
1431
  // the half instead would render a size the caller never asked for with no
1293
1432
  // error to notice, and forwarding it spends a round trip to be told what
1294
1433
  // the shape already said.
1295
- assertOneSizingAnswer(sizing, hasSurface ? "coverage" : "fit");
1296
- return renderPhotoMockup(args, hasSurface ? { surface_uuid: args.surface_uuid } : { uuid: args.print_area_uuid }, sizing);
1434
+ assertOneSizingAnswer(sizing, "fit");
1435
+ return renderTwoD(args, { uuid: args.print_area_uuid }, sizing);
1297
1436
  });
1298
1437
  // ---------------------------------------------------------------------------
1299
- // Tool: list_photo_mockups
1438
+ // Tool: list_2d_mockups
1300
1439
  // ---------------------------------------------------------------------------
1301
- server.tool("list_photo_mockups", "List your saved photo mockup templates, made from product photos with no PSD. Returns each mockup's mockup_id, name, status, thumbnail, dimensions, and print_areas. Use the mockup_id with get_photo_mockup to read the print-area and surface UUIDs a render needs. Costs 0 credits.", {
1440
+ server.tool("list_2d_mockups", "List your saved photo mockup templates (no PSD). Returns each mockup's mockup_id, name, status, thumbnail, dimensions, and print_areas. Use the mockup_id with get_2d_mockup to read the print-area and surface UUIDs a render needs. Costs 0 credits.", {
1302
1441
  limit: z.number().min(1).max(100).default(20).describe("Results per page (1-100, default 20)"),
1303
1442
  offset: z.number().min(0).default(0).describe("Pagination offset (default 0)"),
1304
1443
  customizable_only: z.boolean().default(false).describe("Return only mockups ready for shopper customization"),
1305
1444
  }, async ({ limit, offset, customizable_only }) => {
1306
1445
  const result = await apiRequest({
1307
1446
  method: "GET",
1308
- path: "/api/v1/photo-mockups",
1447
+ path: "/api/v1/sudoai/2d-mockups",
1309
1448
  params: { limit, offset, customizable_only },
1310
1449
  });
1311
1450
  return {
@@ -1316,14 +1455,14 @@ server.tool("list_photo_mockups", "List your saved photo mockup templates, made
1316
1455
  };
1317
1456
  });
1318
1457
  // ---------------------------------------------------------------------------
1319
- // Tool: get_photo_mockup
1458
+ // Tool: get_2d_mockup
1320
1459
  // ---------------------------------------------------------------------------
1321
- server.tool("get_photo_mockup", "Get one photo mockup's full details: the saved print_areas[] somebody drew on it, and the surfaces[] -- one entry per printable product in the photo. Pass a print_area_id as print_area_uuid, or a surfaces[].surface_uuid, to render_photo_mockup. Costs 0 credits.", {
1322
- mockup_id: z.string().describe("UUID of the photo mockup (mockup_id from list_photo_mockups)"),
1460
+ server.tool("get_2d_mockup", "Get one photo mockup's full details: the saved print_areas[] somebody drew on it, and the surfaces[], one entry per printable product in the photo. Pass a print_area_id to render_2d_print_area, or a surfaces[].surface_uuid to render_2d_surface. Costs 0 credits.", {
1461
+ mockup_id: z.string().describe("UUID of the photo mockup (mockup_id from list_2d_mockups)"),
1323
1462
  }, async ({ mockup_id }) => {
1324
1463
  const result = await apiRequest({
1325
1464
  method: "GET",
1326
- path: `/api/v1/photo-mockups/${mockup_id}`,
1465
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}`,
1327
1466
  });
1328
1467
  return {
1329
1468
  content: [{
@@ -1333,9 +1472,9 @@ server.tool("get_photo_mockup", "Get one photo mockup's full details: the saved
1333
1472
  };
1334
1473
  });
1335
1474
  // ---------------------------------------------------------------------------
1336
- // Tool: update_photo_mockup_print_areas
1475
+ // Tool: update_2d_print_areas
1337
1476
  // ---------------------------------------------------------------------------
1338
- server.tool("update_photo_mockup_print_areas", "Replace a photo mockup's print areas with up to 8 four-point quads and return the updated geometry. An empty list is accepted only for verified full product surfaces. Costs 0 credits.", {
1477
+ server.tool("update_2d_print_areas", "Replace a photo mockup's print areas with up to 8 four-point quads and return the updated geometry. An empty list is accepted only for verified full product surfaces. Costs 0 credits.", {
1339
1478
  mockup_id: z.string().describe("UUID of the photo mockup to update"),
1340
1479
  print_areas: z
1341
1480
  .array(z.object({
@@ -1351,7 +1490,7 @@ server.tool("update_photo_mockup_print_areas", "Replace a photo mockup's print a
1351
1490
  }, async ({ mockup_id, print_areas }) => {
1352
1491
  const result = await apiRequest({
1353
1492
  method: "PUT",
1354
- path: `/api/v1/photo-mockups/${mockup_id}/print-areas`,
1493
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}/print-areas`,
1355
1494
  body: { print_areas },
1356
1495
  });
1357
1496
  const envelope = asRecord(result);
@@ -1368,14 +1507,14 @@ server.tool("update_photo_mockup_print_areas", "Replace a photo mockup's print a
1368
1507
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
1369
1508
  });
1370
1509
  // ---------------------------------------------------------------------------
1371
- // Tool: delete_photo_mockup
1510
+ // Tool: delete_2d_mockup
1372
1511
  // ---------------------------------------------------------------------------
1373
- server.tool("delete_photo_mockup", "Permanently delete a photo mockup template and all of its data. Cannot be undone. Costs 0 credits.", {
1374
- mockup_id: z.string().describe("UUID of the photo mockup to delete (mockup_id from list_photo_mockups)"),
1512
+ server.tool("delete_2d_mockup", "Permanently delete a photo mockup template and all of its data. Cannot be undone. Costs 0 credits.", {
1513
+ mockup_id: z.string().describe("UUID of the photo mockup to delete (mockup_id from list_2d_mockups)"),
1375
1514
  }, async ({ mockup_id }) => {
1376
1515
  const result = await apiRequest({
1377
1516
  method: "DELETE",
1378
- path: `/api/v1/photo-mockups/${mockup_id}`,
1517
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}`,
1379
1518
  });
1380
1519
  const envelope = asRecord(result);
1381
1520
  const data = asRecord(envelope.data ?? result);
@@ -1423,7 +1562,7 @@ server.tool("upload_psd", "Upload a Photoshop PSD/PSB file as a new mockup templ
1423
1562
  // ---------------------------------------------------------------------------
1424
1563
  // Tool: get_job
1425
1564
  // ---------------------------------------------------------------------------
1426
- server.tool("get_job", "Get the current status of any async render, video, upload, or photo mockup (2D) job by its job_id. Returns status (queued|dispatched|running|succeeded|failed|cancelled), completed-result details and credits charged, or an error if failed. To block until done, use wait_for_job instead.", {
1565
+ server.tool("get_job", "Get the current status of any async render, video, upload, or photo mockup job by its job_id. Returns status (queued|running|succeeded|failed), completed-result details and credits charged, or an error if failed. To block until done, use wait_for_job instead.", {
1427
1566
  job_id: z.string().describe("The job_id returned by any async submission"),
1428
1567
  }, async ({ job_id }) => {
1429
1568
  const result = await getJob(job_id);
@@ -1432,7 +1571,7 @@ server.tool("get_job", "Get the current status of any async render, video, uploa
1432
1571
  // ---------------------------------------------------------------------------
1433
1572
  // Tool: list_jobs
1434
1573
  // ---------------------------------------------------------------------------
1435
- server.tool("list_jobs", "List your async jobs, including PSD renders, videos, uploads, and photo mockup (2D) creation/renders, newest first. Use this when you do not already hold a job_id. Pass the returned next_cursor to fetch the next page.", {
1574
+ server.tool("list_jobs", "List your async jobs, including PSD renders, videos, uploads, and photo mockup creation and renders, newest first. Use this when you do not already hold a job_id. Pass the returned next_cursor to fetch the next page.", {
1436
1575
  kind: z
1437
1576
  .enum(["video", "render", "upload", "2d_create", "2d_render", "photo_mockup_create", "photo_mockup_render"])
1438
1577
  .optional()
@@ -1455,7 +1594,7 @@ server.tool("list_jobs", "List your async jobs, including PSD renders, videos, u
1455
1594
  // ---------------------------------------------------------------------------
1456
1595
  // Tool: wait_for_job
1457
1596
  // ---------------------------------------------------------------------------
1458
- server.tool("wait_for_job", "Poll any async render, video, upload, or photo mockup (2D) job until it succeeds, fails, or is cancelled, then return the final result and credits charged. Blocks while polling.", {
1597
+ server.tool("wait_for_job", "Poll any async render, video, upload, or photo mockup job until it succeeds or fails, then return the final result and credits charged. Blocks while polling.", {
1459
1598
  job_id: z.string().describe("The job_id to wait on (from an async submission or render_video)"),
1460
1599
  poll_interval_seconds: z
1461
1600
  .number()
@@ -1493,8 +1632,8 @@ server.tool("wait_for_job", "Poll any async render, video, upload, or photo mock
1493
1632
  // Tool: render_video
1494
1633
  // ---------------------------------------------------------------------------
1495
1634
  server.tool("render_video", "Create a short AI video from either a mockup with artwork or a public image URL. Supply exactly one input mode. Always async: returns a job_id immediately for get_job or wait_for_job. Credit cost depends on clip length, audio, and the automatically selected quality. Unsupported durations are rejected.", {
1496
- mockup_uuid: z.string().optional().describe("RENDER MODE: UUID of the PSD mockup to animate (from list_psd_mockups or upload_psd). Required in render mode. In raw-image mode it is an optional association (groups the clip under that mockup's 'Past clips')."),
1497
- smart_object_uuid: z.string().optional().describe("RENDER MODE: UUID of the smart object layer to place artwork on (from get_psd_mockup). Required in render mode; omit in raw-image mode."),
1635
+ mockup_uuid: z.string().optional().describe("RENDER MODE: UUID of the mockup to animate (from list_mockups or upload_psd). Required in render mode. In raw-image mode it is an optional association (groups the clip under that mockup's 'Past clips')."),
1636
+ smart_object_uuid: z.string().optional().describe("RENDER MODE: UUID of the smart object layer to place artwork on (from get_mockup_details). Required in render mode; omit in raw-image mode."),
1498
1637
  artwork_url: z.string().optional().describe("RENDER MODE: public URL of the artwork image (PNG/JPG/WebP) to place on the mockup before animating. Provide this OR artwork_base64. Omit in raw-image mode."),
1499
1638
  artwork_base64: z.string().optional().describe("RENDER MODE: raw base64-encoded artwork bytes (no data: prefix). Provide this OR artwork_url."),
1500
1639
  artwork_content_type: z.enum(["image/png", "image/jpeg", "image/webp", "image/gif"]).optional().describe("MIME type for artwork_base64 (defaults to image/png if omitted)."),
@@ -1581,7 +1720,7 @@ server.tool("render_video", "Create a short AI video from either a mockup with a
1581
1720
  // ---------------------------------------------------------------------------
1582
1721
  // Tool 8: get_account
1583
1722
  // ---------------------------------------------------------------------------
1584
- server.tool("get_account", "Get your account info: subscription plan, remaining credits, prepaid balance, usage stats, billing period, and API key details. An account is funded by a subscription allowance or by a prepaid balance, so read both: an account paying as it goes reports 0 credits and pays from its balance. Quote funding_summary rather than reading the credits fields alone.", {}, async () => {
1723
+ server.tool("get_account", "Get your account info: the organization the API key belongs to, subscription plan, remaining credits, prepaid balance, usage stats, billing period, and API key details. The plan, credits and balance belong to that organization, so name it when you report them. An account is funded by a subscription allowance or by a prepaid balance, so read both: an account paying as it goes reports 0 credits and pays from its balance. Quote funding_summary rather than reading the credits fields alone.", {}, async () => {
1585
1724
  const result = await apiRequest({
1586
1725
  method: "GET",
1587
1726
  path: "/api/v1/me",
@@ -1780,6 +1919,20 @@ server.tool("replay_webhook_delivery", "Replay a single webhook delivery while p
1780
1919
  }],
1781
1920
  };
1782
1921
  });
1922
+ server.tool("replay_failed_webhook_deliveries", "Re-send every failed or dead delivery for a webhook endpoint in one call, e.g. after fixing your endpoint. Returns status and count: how many deliveries were queued to be sent again. To re-send a single delivery, use replay_webhook_delivery.", {
1923
+ endpoint_id: z.string().describe("The id of the webhook endpoint whose failed deliveries are all sent again (from list_webhook_endpoints)"),
1924
+ }, async ({ endpoint_id }) => {
1925
+ const result = await apiRequest({
1926
+ method: "POST",
1927
+ path: `/api/v1/webhook-endpoints/${endpoint_id}/deliveries/replay-failed`,
1928
+ });
1929
+ return {
1930
+ content: [{
1931
+ type: "text",
1932
+ text: JSON.stringify(publicWebhookBulkReplay(result), null, 2),
1933
+ }],
1934
+ };
1935
+ });
1783
1936
  // ---------------------------------------------------------------------------
1784
1937
  // Local file upload
1785
1938
  //
@@ -1887,6 +2040,209 @@ server.tool("upload_local_file", "Upload a file from this machine and get back a
1887
2040
  }],
1888
2041
  };
1889
2042
  });
2043
+ // The same signing call as upload_local_file, with the upload itself left to the
2044
+ // caller: the upload URL comes back in the result instead of being used here.
2045
+ // It is the tool for a file this process cannot read, and the name the hosted
2046
+ // server publishes for the same step.
2047
+ server.tool("create_upload_url", "Get a place to send a file that only exists on your machine. Every other tool takes a URL, so a local file has to be uploaded first. Returns an upload_url to PUT the bytes to and the file_url they will have afterwards; pass that file_url as psd_file_url to upload_psd, or as artwork_url to any render tool. Set kind to \"psd\" for a .psd or .psb template, or \"artwork\" for a .png, .jpg, .webp or .gif image. The upload target is scoped to a single file and cannot be overwritten or read back through the same URL. When the file is on the machine running this server, upload_local_file uploads it in one step. Costs 0 credits.", {
2048
+ filename: z.string().describe("The file's name, e.g. shirt.psd. Only its extension is read, and a type that cannot be processed is refused."),
2049
+ kind: z
2050
+ .enum(["psd", "artwork"])
2051
+ .default("psd")
2052
+ .describe("\"psd\" for a .psd or .psb template, \"artwork\" for a .png, .jpg, .webp or .gif image."),
2053
+ }, async ({ filename, kind }) => {
2054
+ const result = await apiRequest({
2055
+ method: "POST",
2056
+ path: "/api/v1/uploads/sign",
2057
+ body: { filename, kind },
2058
+ });
2059
+ return {
2060
+ content: [{
2061
+ type: "text",
2062
+ text: JSON.stringify(publicUploadTarget(result), null, 2),
2063
+ }],
2064
+ };
2065
+ });
2066
+ // ---------------------------------------------------------------------------
2067
+ // Family names, added beside the names above
2068
+ // ---------------------------------------------------------------------------
2069
+ // The product names its two kinds of template PSD mockups and photo mockups,
2070
+ // and the tools are spelled that way too. Added, not substituted: a name that
2071
+ // shipped once is answered to forever, because a setup written against it is
2072
+ // not something we can see from here, and a caller who reached for the family
2073
+ // spelling in the window it existed is owed the same. Both spellings are
2074
+ // registered, both are supported, and neither is deprecated.
2075
+ /**
2076
+ * Publish an already-registered tool under a second name.
2077
+ *
2078
+ * The two names share one handler and one input schema: the registration
2079
+ * arguments are replayed with a different name, so there is no second copy to
2080
+ * keep in step and no way for the two to drift apart. The original
2081
+ * registration is not touched, including its description. `relation` is how
2082
+ * the added description names the pairing.
2083
+ */
2084
+ function alsoRegisterAs(existing, secondName, relation = "Family-name spelling") {
2085
+ const source = registrations.get(existing);
2086
+ if (source === undefined || source.method !== "tool") {
2087
+ throw new Error(`Cannot publish ${secondName}: ${existing} has no replayable registration.`);
2088
+ }
2089
+ const [, description, ...rest] = source.args;
2090
+ if (typeof description !== "string") {
2091
+ throw new Error(`Cannot publish ${secondName}: ${existing} was registered without a description, ` +
2092
+ "so replaying its arguments would drop the input schema and take any argument.");
2093
+ }
2094
+ server.tool(secondName, `${description} ${relation} of ${existing}: one tool under two names, same arguments, same behavior. Either name works.`, ...rest);
2095
+ }
2096
+ for (const [existing, familyName] of [
2097
+ // PSD mockups.
2098
+ ["list_mockups", "list_psd_mockups"],
2099
+ ["get_mockup_details", "get_psd_mockup"],
2100
+ ["update_mockup", "update_psd_mockup"],
2101
+ ["delete_mockup", "delete_psd_mockup"],
2102
+ ["render_mockup", "render_psd_mockup"],
2103
+ // Photo mockups.
2104
+ ["create_2d_mockup", "create_photo_mockup"],
2105
+ ["list_2d_mockups", "list_photo_mockups"],
2106
+ ["get_2d_mockup", "get_photo_mockup"],
2107
+ ["update_2d_print_areas", "update_photo_mockup_print_areas"],
2108
+ ["delete_2d_mockup", "delete_photo_mockup"],
2109
+ ]) {
2110
+ alsoRegisterAs(existing, familyName);
2111
+ }
2112
+ // ---------------------------------------------------------------------------
2113
+ // Names the hosted server publishes for tools this package already has
2114
+ // ---------------------------------------------------------------------------
2115
+ // The hosted server and this package offer one set of tools, so a setup moved
2116
+ // from one to the other keeps working. Where the hosted server calls a tool
2117
+ // that exists here by another name, that name is added here the same way the
2118
+ // family names are: the same handler and the same input schema.
2119
+ for (const [existing, secondName] of [
2120
+ ["get_2d_mockup", "get_2d_mockup_details"],
2121
+ ["test_webhook_endpoint", "send_webhook_test_event"],
2122
+ ]) {
2123
+ alsoRegisterAs(existing, secondName, "Second name");
2124
+ }
2125
+ // ---------------------------------------------------------------------------
2126
+ // Tool: render_photo_mockup
2127
+ // ---------------------------------------------------------------------------
2128
+ // The one family name that is not a second name for a single tool. Under the
2129
+ // 2D spelling a render picks its target by picking a tool; under the family
2130
+ // spelling one tool takes both kinds and the target is an argument, which is
2131
+ // the shape this name was published with. It is a dispatcher and nothing more:
2132
+ // the sizing rules, the refusals and the request itself are the same functions
2133
+ // `render_2d_surface` and `render_2d_print_area` call, so there is one
2134
+ // implementation of a 2D render on this side no matter which name reaches it.
2135
+ const ONE_TARGET = "Provide exactly one of print_area_uuid or surface_uuid";
2136
+ // Every shared field belongs to this tool too; only the mockup's own argument
2137
+ // is spelled differently, as `mockup_id` -- the name this tool was published
2138
+ // with, and the name the photo mockup tools return the id under.
2139
+ const { mockup_uuid: _twoDMockupUuid, ...PHOTO_RENDER_SHARED } = TWO_D_SHARED;
2140
+ // The target and the dials that size it. They are the same fields whichever
2141
+ // name the render is reached by, so they are written down once.
2142
+ const PHOTO_RENDER_TARGET = {
2143
+ print_area_uuid: z
2144
+ .string()
2145
+ .optional()
2146
+ .describe("UUID of a saved print area from get_photo_mockup's print_areas[] (its print_area_id): a bounded zone somebody drew on the product, such as a chest logo. Omit when surface_uuid is used."),
2147
+ surface_uuid: z
2148
+ .string()
2149
+ .optional()
2150
+ .describe("UUID of a product surface from get_photo_mockup's surfaces[]: a whole printable product, for an all-over print. Omit when print_area_uuid is used."),
2151
+ coverage: z
2152
+ .number()
2153
+ .min(10)
2154
+ .max(100)
2155
+ .optional()
2156
+ .describe("How much of the surface the artwork spans, as a percentage (10-100). Belongs to surface_uuid; sending it with print_area_uuid is refused. Omit to span the whole surface, which is what an all-over print usually wants. Send width and height instead to give the artwork an exact size."),
2157
+ fit: z
2158
+ .enum(["contain", "fill", "cover"])
2159
+ .optional()
2160
+ .describe("How the artwork meets the print area, which it always fills edge to edge: 'contain' keeps the proportions and fits inside (the default), 'fill' stretches to the edges, 'cover' fills and crops the overflow. Belongs to print_area_uuid; sending it with surface_uuid is refused. Leave it out to get 'contain'. To sit inside the area with room around it, send width and height instead."),
2161
+ // A percentage cannot express a box whose proportions differ from the
2162
+ // target's, which is exactly what an artwork resized on a canvas is, so
2163
+ // the exact box belongs to both kinds of target.
2164
+ width: z
2165
+ .number()
2166
+ .min(1)
2167
+ .max(30000)
2168
+ .optional()
2169
+ .describe("Artwork width in pixels, drawn at that exact size instead of by coverage or fit. Send together with height, and without coverage or fit. Width and height are independent, so any aspect ratio is allowed - stretching on one axis only is a supported placement."),
2170
+ height: z
2171
+ .number()
2172
+ .min(1)
2173
+ .max(30000)
2174
+ .optional()
2175
+ .describe("Artwork height in pixels. Send together with width. Sending only one of the two is rejected rather than silently completed, so the aspect ratio is never guessed for you."),
2176
+ };
2177
+ /** The strict input of a one-tool photo mockup render, refusing unknown options by name. */
2178
+ function photoRenderInput(shape) {
2179
+ return z.strictObject(shape, {
2180
+ error: (issue) => {
2181
+ if (issue.code !== "unrecognized_keys")
2182
+ return undefined;
2183
+ return issue.keys
2184
+ .map((key) => key === "scale"
2185
+ ? RETIRED_SCALE
2186
+ : `${key} is not an option on this tool. Send only the options it lists.`)
2187
+ .join(" ");
2188
+ },
2189
+ });
2190
+ }
2191
+ const renderPhotoMockupInput = photoRenderInput({
2192
+ mockup_id: z
2193
+ .string()
2194
+ .describe("UUID of the photo mockup (mockup_id from list_photo_mockups, get_photo_mockup or create_photo_mockup)."),
2195
+ ...PHOTO_RENDER_SHARED,
2196
+ ...PHOTO_RENDER_TARGET,
2197
+ });
2198
+ /**
2199
+ * Render one photo mockup target named by argument rather than by tool.
2200
+ *
2201
+ * Shared by every name that takes the target as an argument, so the refusals
2202
+ * and the request are one implementation whichever of them is called.
2203
+ */
2204
+ async function renderNamedTarget(args, mockupUuid) {
2205
+ const hasPrintArea = args.print_area_uuid !== undefined;
2206
+ const hasSurface = args.surface_uuid !== undefined;
2207
+ if (hasPrintArea === hasSurface)
2208
+ throw new Error(ONE_TARGET);
2209
+ // The dial of the other kind of target is refused by name, in the words
2210
+ // the tool that owns that dial already refuses it with. Refusing is keyed
2211
+ // on the argument being written rather than on the value it holds: writing
2212
+ // it is the caller naming the option.
2213
+ if (hasSurface && args.fit !== undefined)
2214
+ throw new Error(TWO_D_REFUSED_OPTIONS.surface.fit);
2215
+ if (hasPrintArea && args.coverage !== undefined) {
2216
+ throw new Error(TWO_D_REFUSED_OPTIONS.print_area.coverage);
2217
+ }
2218
+ const sizing = {
2219
+ ...(args.coverage === undefined ? {} : { coverage: args.coverage }),
2220
+ ...(args.fit === undefined ? {} : { fit: args.fit }),
2221
+ ...(args.width === undefined ? {} : { width: args.width }),
2222
+ ...(args.height === undefined ? {} : { height: args.height }),
2223
+ };
2224
+ assertOneSizingAnswer(sizing, hasSurface ? "coverage" : "fit");
2225
+ return renderTwoD({ ...args, mockup_uuid: mockupUuid }, hasSurface ? { surface_uuid: args.surface_uuid } : { uuid: args.print_area_uuid }, sizing);
2226
+ }
2227
+ server.registerTool("render_photo_mockup", {
2228
+ description: "Render artwork onto a saved photo mockup. Name exactly one target: a print_area_uuid (a bounded zone somebody drew on the product, such as a chest logo; sized by fit or by width + height) or a surface_uuid (a whole printable product, for an all-over print; sized by coverage or by width + height). Read both from get_photo_mockup. Returns print_files (each with an export_path) and a render_uuid. Costs 5 credits. Family-name spelling of render_2d_surface and render_2d_print_area, which stay available and behave exactly as they did. Use the dashboard for visual fine-tuning.",
2229
+ inputSchema: renderPhotoMockupInput,
2230
+ }, async (args) => renderNamedTarget(args, args.mockup_id));
2231
+ // ---------------------------------------------------------------------------
2232
+ // Tool: render_2d_mockup
2233
+ // ---------------------------------------------------------------------------
2234
+ // The name the hosted server publishes render_photo_mockup under, and the name
2235
+ // this package's single render tool had before the render was split in two.
2236
+ // It is render_photo_mockup under a second name, with one difference in
2237
+ // spelling: the mockup travels as `mockup_uuid`, as it does on
2238
+ // render_2d_surface and render_2d_print_area and as that name always took it.
2239
+ server.registerTool("render_2d_mockup", {
2240
+ description: "Render artwork onto a saved photo mockup. Name exactly one target: a print_area_uuid (a bounded zone somebody drew on the product, such as a chest logo; sized by fit or by width + height) or a surface_uuid (a whole printable product, for an all-over print; sized by coverage or by width + height). Read both from get_2d_mockup. Returns print_files (each with an export_path) and a render_uuid. Costs 5 credits. Second name of render_photo_mockup: the same tool and behavior, with the mockup passed as mockup_uuid. Use the dashboard for visual fine-tuning.",
2241
+ inputSchema: photoRenderInput({
2242
+ ...TWO_D_SHARED,
2243
+ ...PHOTO_RENDER_TARGET,
2244
+ }),
2245
+ }, async (args) => renderNamedTarget(args, args.mockup_uuid));
1890
2246
  // ---------------------------------------------------------------------------
1891
2247
  // Start server
1892
2248
  // ---------------------------------------------------------------------------