@sudomock/mcp 3.0.0 → 3.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/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,20 +802,28 @@ 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
  // ---------------------------------------------------------------------------
819
+ // Every name the API takes for how artwork meets its area, on a smart object,
820
+ // on a photo mockup print area and on a video's still frame alike: 'fill',
821
+ // 'fit' and 'crop', and 'contain' and 'cover', the older names for 'fit' and
822
+ // 'crop', which it still accepts. A name is sent as given. On a smart object
823
+ // this package keeps 'fill' as its default when fit is left out; on a print
824
+ // area nothing is sent and the API's own default applies.
825
+ const FIT_MODES = ["fill", "fit", "crop", "contain", "cover"];
826
+ const SMART_OBJECT_FIT_HELP = "'fill' stretches the artwork to the bounds, 'fit' fits it inside keeping its proportions, 'crop' covers the area keeping its proportions and cuts the overflow. 'contain' and 'cover' are the older names for 'fit' and 'crop' and are still accepted. Default 'fill'.";
751
827
  const smartObjectInputSchema = z
752
828
  .object({
753
829
  uuid: z
@@ -762,18 +838,21 @@ const smartObjectInputSchema = z
762
838
  .enum(["image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"])
763
839
  .optional()
764
840
  .describe("MIME type for base64 artwork; defaults to image/png"),
765
- fit: z.enum(["fill", "contain", "cover"]).default("fill").describe("How artwork fills the smart object area"),
841
+ fit: z
842
+ .enum(FIT_MODES)
843
+ .default("fill")
844
+ .describe(`How the artwork meets the smart object area. ${SMART_OBJECT_FIT_HELP}`),
766
845
  size: z
767
846
  .object({
768
- width: z.number().int().min(1).optional().describe("Artwork width in pixels"),
769
- height: z.number().int().min(1).optional().describe("Artwork height in pixels"),
847
+ width: z.number().min(1).optional().describe("Artwork width in pixels, at least 1. Fractions are accepted."),
848
+ height: z.number().min(1).optional().describe("Artwork height in pixels, at least 1. Fractions are accepted."),
770
849
  })
771
850
  .optional()
772
851
  .describe("Optional custom artwork size"),
773
852
  position: z
774
853
  .object({
775
- top: z.number().int().optional().describe("Top offset in pixels"),
776
- left: z.number().int().optional().describe("Left offset in pixels"),
854
+ top: z.number().optional().describe("Top offset in pixels. Fractions are accepted."),
855
+ left: z.number().optional().describe("Left offset in pixels. Fractions are accepted."),
777
856
  })
778
857
  .optional()
779
858
  .describe("Optional custom artwork position"),
@@ -789,9 +868,20 @@ const smartObjectInputSchema = z
789
868
  .optional(),
790
869
  color: z
791
870
  .object({
792
- hex: z.string().regex(/^#[0-9a-fA-F]{6}$/).describe("Color overlay as a six-digit hex code"),
871
+ hex: z
872
+ .string()
873
+ .regex(/^#[0-9a-fA-F]{6}$/)
874
+ .optional()
875
+ .describe("Color overlay as a six-digit hex code, e.g. '#FF5733'. Send this or label."),
876
+ label: z
877
+ .string()
878
+ .min(1)
879
+ .max(32)
880
+ .optional()
881
+ .describe("The name of a color saved on this mockup, e.g. 'blue jean', matched exactly. Send this instead of hex."),
793
882
  blending_mode: z.string().default("normal").describe("Blend mode for the color overlay"),
794
883
  })
884
+ .refine((color) => (color.hex === undefined) !== (color.label === undefined), "Provide exactly one of color.hex or color.label")
795
885
  .optional(),
796
886
  adjustment_layers: z
797
887
  .object({
@@ -811,7 +901,7 @@ const textLayerInputSchema = z
811
901
  uuid: z
812
902
  .string()
813
903
  .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"),
904
+ .describe("UUID from get_mockup_details text_layers"),
815
905
  text: z.string().min(1).max(500).optional().describe("Replacement text for a single-style layer"),
816
906
  segments: z
817
907
  .array(z.object({
@@ -836,6 +926,10 @@ const textLayerInputSchema = z
836
926
  .enum(["shrink", "clip", "overflow"])
837
927
  .default("overflow")
838
928
  .describe("Long-text handling for single-style point text; default overflow"),
929
+ vertical_align: z
930
+ .enum(["top", "bottom", "center"])
931
+ .optional()
932
+ .describe("Where text that fit 'shrink' scaled down sits in the original area: 'top' (the default), 'center' or 'bottom'. Single-style point text only."),
839
933
  })
840
934
  .superRefine((layer, ctx) => {
841
935
  if ((layer.text === undefined) === (layer.segments === undefined)) {
@@ -858,22 +952,121 @@ const textLayerInputSchema = z
858
952
  ctx.addIssue({ code: "custom", message: "Segment indexes must be unique" });
859
953
  }
860
954
  });
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)"),
863
- 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
- artwork_url: z.string().optional().describe("Public artwork URL for smart_object_uuid. Provide both singular fields, or use smart_objects."),
955
+ const LAYER_UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
956
+ const HEX_COLOR = /^#?[0-9a-fA-F]{6}$/;
957
+ /** One outline color, or up to eight front to back; null keeps an authored color. */
958
+ function outlineColors() {
959
+ return z.union([
960
+ z.string().regex(HEX_COLOR),
961
+ z.array(z.string().regex(HEX_COLOR).nullable()).min(1).max(8),
962
+ ]);
963
+ }
964
+ // One text layer and one group written as flat arguments: the names the hosted
965
+ // server publishes on render_mockup. They become a single text_layers entry and
966
+ // a single group_layers entry, built by the same rules the hosted server builds
967
+ // them with (singleTextLayer and singleGroupLayer below), so the same flat
968
+ // arguments put the same text_layers and group_layers entries on the wire from
969
+ // either server.
970
+ const SINGLE_LAYER_SHORTCUT = {
971
+ text_layer_uuid: z
972
+ .string()
973
+ .regex(LAYER_UUID)
974
+ .optional()
975
+ .describe("UUID of one editable text layer from get_mockup_details text_layers. Send with text or text_segments; it becomes one text_layers entry, placed before any text_layers entries sent with it."),
976
+ text: z
977
+ .string()
978
+ .min(1)
979
+ .max(500)
980
+ .optional()
981
+ .describe("Replacement text for the text_layer_uuid layer when it has one style (segment_count 1). Use text_segments for a styled layer."),
982
+ text_segments: z
983
+ .array(z.object({
984
+ index: z.number().int().min(0).max(31).describe("Zero-based segment index"),
985
+ text: z.string().min(1).max(200).describe("Replacement text for this segment"),
986
+ }))
987
+ .min(1)
988
+ .max(32)
989
+ .optional()
990
+ .describe("For a styled text_layer_uuid layer (segment_count above 1): {index, text} entries. Listed segments are replaced, the rest keep their text, and each keeps its own styling. Used instead of text."),
991
+ text_font: z.string().max(255).optional().describe("Font UUID or PostScript name from list_fonts. Single-style layers only. Omit to keep the layer's font."),
992
+ text_font_size: z.number().positive().max(2000).optional().describe("Font size in pixels at the mockup's native resolution. Single-style layers only. Omit to keep the original size."),
993
+ text_color: z.string().regex(HEX_COLOR).optional().describe("Text color as a six-digit hex code, e.g. '#1A1A1A'. Single-style layers only. Omit to keep the original color."),
994
+ text_stroke_color: outlineColors()
995
+ .optional()
996
+ .describe("Colors for the text layer's own outlines: one hex code for the front outline, or up to eight front to back; null keeps an authored color. Single-style layers only."),
997
+ text_fit: z
998
+ .enum(["shrink", "clip", "overflow"])
999
+ .optional()
1000
+ .describe("Long-text handling for single-style point text: 'overflow' keeps the designed size, 'shrink' scales it down to fit, 'clip' cuts it at the last character that fits. Omit for 'overflow'."),
1001
+ text_vertical_align: z
1002
+ .enum(["top", "bottom", "center"])
1003
+ .optional()
1004
+ .describe("Where text that text_fit 'shrink' scaled down sits in the original area: 'top' (the default), 'center' or 'bottom'. Single-style layers only."),
1005
+ group_layer_uuid: z
1006
+ .string()
1007
+ .regex(LAYER_UUID)
1008
+ .optional()
1009
+ .describe("UUID of an editable group layer from the mockup's group_layers. Send with group_stroke_color."),
1010
+ group_stroke_color: outlineColors()
1011
+ .optional()
1012
+ .describe("Colors for that group's outlines: one hex code for the front outline, or up to eight front to back; null keeps an authored color. The change affects everything inside the group."),
1013
+ };
1014
+ /**
1015
+ * The text_layers entry the flat text arguments describe, or undefined when
1016
+ * they describe none. The hosted server's rules, one for one: the entry needs a
1017
+ * layer UUID and either segments or text; segments win when both arrive and
1018
+ * then travel without styling, because each segment keeps its own; a styling
1019
+ * argument left out is left off the wire.
1020
+ */
1021
+ function singleTextLayer(args) {
1022
+ if (!args.text_layer_uuid || (!args.text_segments && !args.text))
1023
+ return undefined;
1024
+ const layer = { uuid: args.text_layer_uuid };
1025
+ if (args.text_segments) {
1026
+ layer.segments = args.text_segments;
1027
+ return layer;
1028
+ }
1029
+ layer.text = args.text;
1030
+ if (args.text_font)
1031
+ layer.font = args.text_font;
1032
+ if (args.text_font_size)
1033
+ layer.font_size = args.text_font_size;
1034
+ if (args.text_color)
1035
+ layer.color = args.text_color;
1036
+ if (args.text_stroke_color)
1037
+ layer.stroke_color = args.text_stroke_color;
1038
+ if (args.text_fit)
1039
+ layer.fit = args.text_fit;
1040
+ if (args.text_vertical_align)
1041
+ layer.vertical_align = args.text_vertical_align;
1042
+ return layer;
1043
+ }
1044
+ /** The group_layers entry the flat group arguments describe; both halves are needed. */
1045
+ function singleGroupLayer(args) {
1046
+ if (!args.group_layer_uuid || args.group_stroke_color === undefined)
1047
+ return undefined;
1048
+ return { uuid: args.group_layer_uuid, stroke_color: args.group_stroke_color };
1049
+ }
1050
+ 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. Name what to fill with any of these, alone or together: smart_object_uuid + artwork_url for one smart object, smart_objects entries ({uuid, asset or color}) for one or more, text_layers entries ({uuid, text or segments}), or text_layer_uuid + text for one text layer. Sent together they are combined into one render: the smart_object_uuid entry comes before the smart_objects entries, and the text_layer_uuid entry before the text_layers entries. A call that names nothing to render returns an error. Returns the rendered image URL. Costs 1 credit. Use list_mockups and get_mockup_details to find target UUIDs.", {
1051
+ mockup_uuid: z.string().describe("UUID of the mockup template (from list_mockups)"),
1052
+ smart_object_uuid: z.string().optional().describe("UUID of one smart object layer (from get_mockup_details). Send with artwork_url, or leave both out and use smart_objects or text_layers. With smart_objects, this entry comes first."),
1053
+ artwork_url: z.string().optional().describe("Public artwork URL for smart_object_uuid. Send the two together, or leave both out and use smart_objects or text_layers."),
1054
+ // An empty list is accepted and counts as not sent, as on the hosted
1055
+ // server: smart_objects: [] beside the single pair renders the pair.
865
1056
  smart_objects: z
866
1057
  .array(smartObjectInputSchema)
867
- .min(1)
868
1058
  .optional()
869
- .describe("One or more smart object overrides, each with asset or color. Do not combine with smart_object_uuid/artwork_url."),
1059
+ .describe("Smart objects to fill, each with a uuid and asset or color. Works on its own; with smart_object_uuid these entries follow that one."),
870
1060
  text_layers: z
871
1061
  .array(textLayerInputSchema)
872
- .min(1)
873
1062
  .max(50)
874
1063
  .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."),
876
- fit: z.enum(["fill", "contain", "cover"]).default("fill").describe("How singular artwork_url fills its smart object area"),
1064
+ .describe("Text layer overrides from get_mockup_details, each with a uuid and exactly one of text or segments. Works on its own; with text_layer_uuid these entries follow that one."),
1065
+ ...SINGLE_LAYER_SHORTCUT,
1066
+ fit: z
1067
+ .enum(FIT_MODES)
1068
+ .default("fill")
1069
+ .describe(`How the singular artwork_url meets its smart object area. ${SMART_OBJECT_FIT_HELP}`),
877
1070
  image_format: z.enum(["webp", "png", "jpg"]).default("webp").describe("Output format"),
878
1071
  image_size: z.number().min(100).max(10000).default(2048).describe("Output width in pixels (default 2048)"),
879
1072
  quality: z.number().min(1).max(100).default(90).describe("Compression quality for webp/jpg (default 90)"),
@@ -899,17 +1092,24 @@ server.tool("render_psd_mockup", "Render a PSD mockup with artwork, editable tex
899
1092
  const hasSmartObjectUuid = args.smart_object_uuid !== undefined;
900
1093
  const hasArtworkUrl = args.artwork_url !== undefined;
901
1094
  if (hasSmartObjectUuid !== hasArtworkUrl) {
902
- throw new Error("Provide both smart_object_uuid and artwork_url, or use smart_objects.");
903
- }
904
- if (args.smart_objects && hasSmartObjectUuid) {
905
- throw new Error("Provide smart_objects or smart_object_uuid/artwork_url, not both.");
1095
+ throw new Error("Provide smart_object_uuid and artwork_url together, or leave both out and use smart_objects or text_layers.");
906
1096
  }
907
- if (!args.smart_objects && !hasSmartObjectUuid && !args.text_layers) {
908
- throw new Error("Provide smart_objects, smart_object_uuid/artwork_url, or text_layers.");
1097
+ const shortcutTextLayer = singleTextLayer(args);
1098
+ const shortcutGroupLayer = singleGroupLayer(args);
1099
+ // An empty list names nothing, so it counts as not sent.
1100
+ const listedSmartObjects = args.smart_objects ?? [];
1101
+ const listedTextLayers = args.text_layers ?? [];
1102
+ if (!listedSmartObjects.length &&
1103
+ !hasSmartObjectUuid &&
1104
+ !listedTextLayers.length &&
1105
+ !shortcutTextLayer &&
1106
+ !shortcutGroupLayer) {
1107
+ throw new Error("Nothing to render: provide smart_object_uuid with artwork_url, smart_objects, text_layers, text_layer_uuid with text or text_segments, or group_layer_uuid with group_stroke_color.");
909
1108
  }
910
- const smartObjects = args.smart_objects
911
- ? [...args.smart_objects]
912
- : [];
1109
+ // The single pair and the lists are combined the way the hosted server
1110
+ // combines them: the single entry first, then every list entry as given.
1111
+ // An entry that names the same uuid twice reaches the API twice.
1112
+ const smartObjects = [];
913
1113
  if (hasSmartObjectUuid && hasArtworkUrl) {
914
1114
  const smartObject = {
915
1115
  uuid: args.smart_object_uuid,
@@ -945,6 +1145,11 @@ server.tool("render_psd_mockup", "Render a PSD mockup with artwork, editable tex
945
1145
  }
946
1146
  smartObjects.push(smartObject);
947
1147
  }
1148
+ smartObjects.push(...listedSmartObjects);
1149
+ const textLayers = [
1150
+ ...(shortcutTextLayer ? [shortcutTextLayer] : []),
1151
+ ...listedTextLayers,
1152
+ ];
948
1153
  const body = {
949
1154
  mockup_uuid: args.mockup_uuid,
950
1155
  export_options: {
@@ -956,8 +1161,10 @@ server.tool("render_psd_mockup", "Render a PSD mockup with artwork, editable tex
956
1161
  };
957
1162
  if (smartObjects.length)
958
1163
  body.smart_objects = smartObjects;
959
- if (args.text_layers)
960
- body.text_layers = args.text_layers;
1164
+ if (textLayers.length)
1165
+ body.text_layers = textLayers;
1166
+ if (shortcutGroupLayer)
1167
+ body.group_layers = [shortcutGroupLayer];
961
1168
  if (args.export_label) {
962
1169
  body.export_label = args.export_label;
963
1170
  }
@@ -1002,9 +1209,40 @@ server.tool("remove_background", "Remove the background from any image and retur
1002
1209
  };
1003
1210
  });
1004
1211
  // ---------------------------------------------------------------------------
1005
- // Tool: create_photo_mockup
1212
+ // Tool: list_fonts
1006
1213
  // ---------------------------------------------------------------------------
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.", {
1214
+ 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.", {
1215
+ search: z.string().optional().describe("Filter by family name (case-insensitive contains), e.g. 'sans'"),
1216
+ category: z.string().optional().describe("Filter by category, e.g. 'serif', 'sans-serif', 'display'"),
1217
+ scope: z
1218
+ .enum(["all", "system", "custom"])
1219
+ .default("all")
1220
+ .describe("Which fonts to return: 'all' (system fonts plus your uploads, the default), 'system', or 'custom' (your uploads only)"),
1221
+ page: z.number().int().min(1).default(1).describe("Page number (1-based, default 1)"),
1222
+ per_page: z.number().int().min(1).max(100).default(50).describe("Results per page (1-100, default 50)"),
1223
+ }, async ({ search, category, scope, page, per_page }) => {
1224
+ const result = await apiRequest({
1225
+ method: "GET",
1226
+ path: "/api/v1/fonts",
1227
+ params: {
1228
+ page,
1229
+ per_page,
1230
+ scope,
1231
+ search: search || undefined,
1232
+ category: category || undefined,
1233
+ },
1234
+ });
1235
+ return {
1236
+ content: [{
1237
+ type: "text",
1238
+ text: JSON.stringify(publicFontList(result), null, 2),
1239
+ }],
1240
+ };
1241
+ });
1242
+ // ---------------------------------------------------------------------------
1243
+ // Tool: create_2d_mockup
1244
+ // ---------------------------------------------------------------------------
1245
+ 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
1246
  source_url: z.string().describe("Public HTTPS URL of the product image"),
1009
1247
  name: z.string().optional().describe("Optional display name for the photo mockup"),
1010
1248
  idempotency_key: z.string().min(1).max(255).optional().describe("Optional retry-stable key for this create request"),
@@ -1020,7 +1258,7 @@ server.tool("create_photo_mockup", "Create a reusable photo mockup from a public
1020
1258
  body.is_async = true;
1021
1259
  const result = (await apiRequest({
1022
1260
  method: "POST",
1023
- path: "/api/v1/photo-mockups",
1261
+ path: "/api/v1/sudoai/2d-mockups",
1024
1262
  body,
1025
1263
  headers: { "Idempotency-Key": idempotency_key ?? randomUUID() },
1026
1264
  }));
@@ -1054,16 +1292,15 @@ server.tool("create_photo_mockup", "Create a reusable photo mockup from a public
1054
1292
  return { content: [{ type: "text", text: JSON.stringify(summary, null, 2) }] };
1055
1293
  });
1056
1294
  // ---------------------------------------------------------------------------
1057
- // Tool: render_photo_mockup
1295
+ // 2D render: one tool per kind of target
1058
1296
  // ---------------------------------------------------------------------------
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)."),
1297
+ // A render names one target and nothing else. A product surface takes a
1298
+ // coverage percentage; a print area takes a fit or an explicit box. Two tools
1299
+ // rather than one, because those are two different dials and a single tool
1300
+ // could only describe in prose what the shape can state outright: whichever
1301
+ // tool the caller reaches for, every field on it applies.
1302
+ const TWO_D_SHARED = {
1303
+ mockup_uuid: z.string().describe("UUID of the photo mockup template (from list_2d_mockups, returned as mockup_id)."),
1067
1304
  artwork_url: z.string().describe("Public URL of the artwork image (PNG/JPG/WebP) to place on the mockup"),
1068
1305
  remove_background: z.boolean().default(false).describe("Remove the artwork's background before placing it. Adds 25 credits per artwork."),
1069
1306
  opacity: z.number().min(0).max(100).default(100).describe("Artwork opacity percentage (0-100)"),
@@ -1107,82 +1344,74 @@ const PHOTO_RENDER_SHARED = {
1107
1344
  is_async: z
1108
1345
  .boolean()
1109
1346
  .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."),
1347
+ .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
1348
  };
1112
- // The sentences a refused option is answered with.
1349
+ // The option a caller can write on one of these two tools but not the other,
1350
+ // and the sentence that sends them to the one that works.
1351
+ //
1352
+ // An argument a tool never declared is dropped before the handler ever sees it,
1353
+ // so `coverage` on a print area, `fit` on a surface, and the retired `scale` on
1354
+ // either used to come back as a successful render with the caller's option
1355
+ // gone and nothing said about it. The API answers all three with a 422. The
1356
+ // consumer on this side is a language model: an option it wrote disappearing in
1357
+ // silence teaches it nothing and it writes the same option again, while one
1358
+ // named sentence teaches it in a single turn.
1113
1359
  //
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
1360
+ // The wording is the API's own, so the caller reads the same reason whichever
1121
1361
  // 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
1362
  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.";
1363
+ const TWO_D_REFUSED_OPTIONS = {
1364
+ surface: {
1365
+ 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.",
1366
+ scale: RETIRED_SCALE,
1367
+ },
1368
+ print_area: {
1369
+ 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.",
1370
+ scale: RETIRED_SCALE,
1371
+ },
1372
+ };
1126
1373
  /**
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.
1374
+ * Build the arguments one 2D render tool accepts, and refuse the rest by name.
1375
+ *
1376
+ * Strict on purpose, and only on these two tools: the pair exists precisely
1377
+ * because the sizing options are split between them, so an option landing on
1378
+ * the wrong one of the two is the mistake most worth catching. Refusing is
1379
+ * keyed on the argument being written rather than on the value it holds, so an
1380
+ * explicitly written null is caught as well: writing it is the caller naming
1381
+ * the option.
1382
+ *
1383
+ * An option with no sentence of its own still gets refused rather than dropped.
1384
+ * A name we never published is either a typo or an option the caller believes
1385
+ * exists, and both are worth one line of reply.
1132
1386
  */
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
- });
1387
+ function twoDRenderInput(kind, shape) {
1388
+ const refused = TWO_D_REFUSED_OPTIONS[kind];
1389
+ return z.strictObject(shape, {
1390
+ error: (issue) => {
1391
+ if (issue.code !== "unrecognized_keys")
1392
+ return undefined;
1393
+ return issue.keys
1394
+ .map((key) => refused[key] ??
1395
+ `${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.`)
1396
+ .join(" ");
1397
+ },
1398
+ });
1399
+ }
1179
1400
  /**
1180
1401
  * Refuse a placement that answers the sizing question more than once, or half.
1181
1402
  *
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.
1403
+ * The API refuses these too, and refusing them here as well is deliberate. A
1404
+ * client that can put a shape on the wire the API is guaranteed to reject has
1405
+ * spent the caller's round trip telling them something it already knew.
1406
+ *
1407
+ * This is not the thing that was taken out of here. A default invents a value
1408
+ * the caller never typed and quietly changes the render when it drifts from
1409
+ * the renderer's; a refusal invents nothing, and if it ever drifts it drifts
1410
+ * into refusing out loud, with the API still refusing behind it. It also
1411
+ * cannot drift unnoticed: the contract test reads the shared placement wire
1412
+ * fixture and asserts every shape listed there as rejected is still
1413
+ * unreachable from every tool, and the wording below is the wording the API
1414
+ * answers with, so a caller reads the same reason from either side.
1186
1415
  *
1187
1416
  * `relativeSizing` is whichever proportional option belongs to this kind of
1188
1417
  * target -- `coverage` on a surface, `fit` on a print area.
@@ -1200,15 +1429,16 @@ function assertOneSizingAnswer(placement, relativeSizing) {
1200
1429
  /**
1201
1430
  * Send one render and hand back what the caller can act on.
1202
1431
  *
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.
1432
+ * `target` carries the address field, `placement` the dials that belong to that
1433
+ * kind of target. Neither is inspected here: the dials a caller may turn are
1434
+ * settled by which tool they called, so there is nothing left to check.
1206
1435
  */
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.
1436
+ async function renderTwoD(args, target, placement) {
1437
+ // Anchoring belongs to both kinds of target; only sizing is split, and the
1438
+ // caller's tool already settled which sizing options exist. What the caller
1439
+ // did not name is absent, and a placement nobody touched is absent entirely
1440
+ // rather than sent as an empty object -- an empty object still claims the
1441
+ // caller reached for placement.
1212
1442
  const sent = {
1213
1443
  ...(args.position === undefined ? {} : { position: args.position }),
1214
1444
  ...(args.offset_x === undefined ? {} : { offset_x: args.offset_x }),
@@ -1223,6 +1453,7 @@ async function renderPhotoMockup(args, target, placement) {
1223
1453
  artwork_url: args.artwork_url,
1224
1454
  ...(args.remove_background ? { remove_background: true } : {}),
1225
1455
  adjustments: {
1456
+ ...(args.blend_mode === undefined ? {} : { blend_mode: args.blend_mode }),
1226
1457
  opacity: args.opacity,
1227
1458
  brightness: args.brightness,
1228
1459
  contrast: args.contrast,
@@ -1241,13 +1472,13 @@ async function renderPhotoMockup(args, target, placement) {
1241
1472
  body.is_async = true;
1242
1473
  const result = await apiRequest({
1243
1474
  method: "POST",
1244
- path: `/api/v1/photo-mockups/${args.mockup_id}/render`,
1475
+ path: `/api/v1/sudoai/2d-mockups/${args.mockup_uuid}/render`,
1245
1476
  body,
1246
1477
  timeout: RENDER_TIMEOUT,
1247
1478
  });
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).
1479
+ // is_async=true -> 202 + job_id (kind "2d_render" on this path; the family
1480
+ // spelling is "photo_mockup_render"); hand back the poll contract.
1481
+ // Reuse get_job / wait_for_job to reach the terminal job (result_url).
1251
1482
  if (args.is_async) {
1252
1483
  return { content: [{ type: "text", text: formatJobAccepted(result) }] };
1253
1484
  }
@@ -1267,23 +1498,71 @@ async function renderPhotoMockup(args, target, placement) {
1267
1498
  };
1268
1499
  return { content: [{ type: "text", text: JSON.stringify(rendered, null, 2) }] };
1269
1500
  }
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,
1501
+ // ---------------------------------------------------------------------------
1502
+ // Tool: render_2d_surface
1503
+ // ---------------------------------------------------------------------------
1504
+ server.registerTool("render_2d_surface", {
1505
+ 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.",
1506
+ inputSchema: twoDRenderInput("surface", {
1507
+ ...TWO_D_SHARED,
1508
+ surface_uuid: z.string().describe("UUID of a product surface from get_2d_mockup's surfaces[]."),
1509
+ coverage: z
1510
+ .number()
1511
+ .min(10)
1512
+ .max(100)
1513
+ .optional()
1514
+ .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."),
1515
+ // A percentage cannot express a box whose proportions differ from the
1516
+ // surface's, which is exactly what an artwork resized on a canvas is.
1517
+ width: z
1518
+ .number()
1519
+ .min(1)
1520
+ .max(30000)
1521
+ .optional()
1522
+ .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."),
1523
+ height: z
1524
+ .number()
1525
+ .min(1)
1526
+ .max(30000)
1527
+ .optional()
1528
+ .describe("Artwork height in pixels. Send together with width, and without coverage. The two axes are independent, so any aspect ratio is allowed."),
1529
+ }),
1273
1530
  }, 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
1531
  const sizing = {
1286
1532
  ...(args.coverage === undefined ? {} : { coverage: args.coverage }),
1533
+ ...(args.width === undefined ? {} : { width: args.width }),
1534
+ ...(args.height === undefined ? {} : { height: args.height }),
1535
+ };
1536
+ assertOneSizingAnswer(sizing, "coverage");
1537
+ return renderTwoD(args, { surface_uuid: args.surface_uuid }, sizing);
1538
+ });
1539
+ // ---------------------------------------------------------------------------
1540
+ // Tool: render_2d_print_area
1541
+ // ---------------------------------------------------------------------------
1542
+ server.registerTool("render_2d_print_area", {
1543
+ 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.",
1544
+ inputSchema: twoDRenderInput("print_area", {
1545
+ ...TWO_D_SHARED,
1546
+ print_area_uuid: z.string().describe("UUID of a saved print area from get_2d_mockup's print_areas[] (its print_area_id)."),
1547
+ fit: z
1548
+ .enum(FIT_MODES)
1549
+ .optional()
1550
+ .describe("How the artwork meets the print area, which it always fills edge to edge: 'fit' keeps the proportions and fits inside (the default), 'fill' stretches to the edges, 'crop' fills and crops the overflow. 'contain' and 'cover' are the older names for 'fit' and 'crop' and are still accepted. Leave it out to get 'fit'. To sit inside the area with room around it, send width and height instead."),
1551
+ width: z
1552
+ .number()
1553
+ .min(1)
1554
+ .max(30000)
1555
+ .optional()
1556
+ .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."),
1557
+ height: z
1558
+ .number()
1559
+ .min(1)
1560
+ .max(30000)
1561
+ .optional()
1562
+ .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."),
1563
+ }),
1564
+ }, async (args) => {
1565
+ const sizing = {
1287
1566
  ...(args.fit === undefined ? {} : { fit: args.fit }),
1288
1567
  ...(args.width === undefined ? {} : { width: args.width }),
1289
1568
  ...(args.height === undefined ? {} : { height: args.height }),
@@ -1292,20 +1571,20 @@ server.registerTool("render_photo_mockup", {
1292
1571
  // the half instead would render a size the caller never asked for with no
1293
1572
  // error to notice, and forwarding it spends a round trip to be told what
1294
1573
  // 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);
1574
+ assertOneSizingAnswer(sizing, "fit");
1575
+ return renderTwoD(args, { uuid: args.print_area_uuid }, sizing);
1297
1576
  });
1298
1577
  // ---------------------------------------------------------------------------
1299
- // Tool: list_photo_mockups
1578
+ // Tool: list_2d_mockups
1300
1579
  // ---------------------------------------------------------------------------
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.", {
1580
+ 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
1581
  limit: z.number().min(1).max(100).default(20).describe("Results per page (1-100, default 20)"),
1303
1582
  offset: z.number().min(0).default(0).describe("Pagination offset (default 0)"),
1304
1583
  customizable_only: z.boolean().default(false).describe("Return only mockups ready for shopper customization"),
1305
1584
  }, async ({ limit, offset, customizable_only }) => {
1306
1585
  const result = await apiRequest({
1307
1586
  method: "GET",
1308
- path: "/api/v1/photo-mockups",
1587
+ path: "/api/v1/sudoai/2d-mockups",
1309
1588
  params: { limit, offset, customizable_only },
1310
1589
  });
1311
1590
  return {
@@ -1316,14 +1595,14 @@ server.tool("list_photo_mockups", "List your saved photo mockup templates, made
1316
1595
  };
1317
1596
  });
1318
1597
  // ---------------------------------------------------------------------------
1319
- // Tool: get_photo_mockup
1598
+ // Tool: get_2d_mockup
1320
1599
  // ---------------------------------------------------------------------------
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)"),
1600
+ 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.", {
1601
+ mockup_id: z.string().describe("UUID of the photo mockup (mockup_id from list_2d_mockups)"),
1323
1602
  }, async ({ mockup_id }) => {
1324
1603
  const result = await apiRequest({
1325
1604
  method: "GET",
1326
- path: `/api/v1/photo-mockups/${mockup_id}`,
1605
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}`,
1327
1606
  });
1328
1607
  return {
1329
1608
  content: [{
@@ -1333,9 +1612,9 @@ server.tool("get_photo_mockup", "Get one photo mockup's full details: the saved
1333
1612
  };
1334
1613
  });
1335
1614
  // ---------------------------------------------------------------------------
1336
- // Tool: update_photo_mockup_print_areas
1615
+ // Tool: update_2d_print_areas
1337
1616
  // ---------------------------------------------------------------------------
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.", {
1617
+ 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
1618
  mockup_id: z.string().describe("UUID of the photo mockup to update"),
1340
1619
  print_areas: z
1341
1620
  .array(z.object({
@@ -1351,7 +1630,7 @@ server.tool("update_photo_mockup_print_areas", "Replace a photo mockup's print a
1351
1630
  }, async ({ mockup_id, print_areas }) => {
1352
1631
  const result = await apiRequest({
1353
1632
  method: "PUT",
1354
- path: `/api/v1/photo-mockups/${mockup_id}/print-areas`,
1633
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}/print-areas`,
1355
1634
  body: { print_areas },
1356
1635
  });
1357
1636
  const envelope = asRecord(result);
@@ -1368,14 +1647,14 @@ server.tool("update_photo_mockup_print_areas", "Replace a photo mockup's print a
1368
1647
  return { content: [{ type: "text", text: JSON.stringify(output, null, 2) }] };
1369
1648
  });
1370
1649
  // ---------------------------------------------------------------------------
1371
- // Tool: delete_photo_mockup
1650
+ // Tool: delete_2d_mockup
1372
1651
  // ---------------------------------------------------------------------------
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)"),
1652
+ server.tool("delete_2d_mockup", "Permanently delete a photo mockup template and all of its data. Cannot be undone. Costs 0 credits.", {
1653
+ mockup_id: z.string().describe("UUID of the photo mockup to delete (mockup_id from list_2d_mockups)"),
1375
1654
  }, async ({ mockup_id }) => {
1376
1655
  const result = await apiRequest({
1377
1656
  method: "DELETE",
1378
- path: `/api/v1/photo-mockups/${mockup_id}`,
1657
+ path: `/api/v1/sudoai/2d-mockups/${mockup_id}`,
1379
1658
  });
1380
1659
  const envelope = asRecord(result);
1381
1660
  const data = asRecord(envelope.data ?? result);
@@ -1423,7 +1702,7 @@ server.tool("upload_psd", "Upload a Photoshop PSD/PSB file as a new mockup templ
1423
1702
  // ---------------------------------------------------------------------------
1424
1703
  // Tool: get_job
1425
1704
  // ---------------------------------------------------------------------------
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.", {
1705
+ 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
1706
  job_id: z.string().describe("The job_id returned by any async submission"),
1428
1707
  }, async ({ job_id }) => {
1429
1708
  const result = await getJob(job_id);
@@ -1432,7 +1711,7 @@ server.tool("get_job", "Get the current status of any async render, video, uploa
1432
1711
  // ---------------------------------------------------------------------------
1433
1712
  // Tool: list_jobs
1434
1713
  // ---------------------------------------------------------------------------
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.", {
1714
+ 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
1715
  kind: z
1437
1716
  .enum(["video", "render", "upload", "2d_create", "2d_render", "photo_mockup_create", "photo_mockup_render"])
1438
1717
  .optional()
@@ -1455,7 +1734,7 @@ server.tool("list_jobs", "List your async jobs, including PSD renders, videos, u
1455
1734
  // ---------------------------------------------------------------------------
1456
1735
  // Tool: wait_for_job
1457
1736
  // ---------------------------------------------------------------------------
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.", {
1737
+ 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
1738
  job_id: z.string().describe("The job_id to wait on (from an async submission or render_video)"),
1460
1739
  poll_interval_seconds: z
1461
1740
  .number()
@@ -1493,13 +1772,13 @@ server.tool("wait_for_job", "Poll any async render, video, upload, or photo mock
1493
1772
  // Tool: render_video
1494
1773
  // ---------------------------------------------------------------------------
1495
1774
  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."),
1775
+ 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')."),
1776
+ 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
1777
  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
1778
  artwork_base64: z.string().optional().describe("RENDER MODE: raw base64-encoded artwork bytes (no data: prefix). Provide this OR artwork_url."),
1500
1779
  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)."),
1501
1780
  image_url: z.string().optional().describe("RAW-IMAGE MODE: a public https png/jpg URL to animate without a mockup. Supply this OR (mockup_uuid + smart_object_uuid + artwork), never both."),
1502
- fit: z.enum(["fill", "contain", "cover"]).default("fill").describe("RENDER MODE: how artwork fills the smart object area in the still frame"),
1781
+ fit: z.enum(FIT_MODES).default("fill").describe(`RENDER MODE: how the artwork meets the smart object area in the still frame. ${SMART_OBJECT_FIT_HELP}`),
1503
1782
  asset_width: z.number().int().min(1).optional().describe("RENDER MODE: custom artwork width in pixels (overrides fit sizing)."),
1504
1783
  asset_height: z.number().int().min(1).optional().describe("RENDER MODE: custom artwork height in pixels (overrides fit sizing)."),
1505
1784
  asset_top: z.number().int().optional().describe("RENDER MODE: artwork top offset in pixels within the smart object area."),
@@ -1519,6 +1798,10 @@ server.tool("render_video", "Create a short AI video from either a mockup with a
1519
1798
  .enum(["ambient", "showcase"])
1520
1799
  .default("ambient")
1521
1800
  .describe("'ambient' = subtle looping hero motion that keeps the print readable; 'showcase' = one deliberate camera/product move."),
1801
+ advanced_model: z
1802
+ .string()
1803
+ .optional()
1804
+ .describe("Advanced override for the automatic quality selection. Unsupported values are rejected. Omit for automatic selection (recommended)."),
1522
1805
  image_format: z.enum(["webp", "png", "jpg"]).default("webp").describe("Output format of the still input frame"),
1523
1806
  image_size: z.number().min(100).max(10000).default(2048).describe("Width in pixels of the still input frame (default 2048)"),
1524
1807
  quality: z.number().min(1).max(100).default(90).describe("Compression quality for the still input frame (webp/jpg, default 90)"),
@@ -1532,6 +1815,8 @@ server.tool("render_video", "Create a short AI video from either a mockup with a
1532
1815
  audio: args.audio,
1533
1816
  motion: args.motion,
1534
1817
  };
1818
+ if (args.advanced_model)
1819
+ video.advanced_model = args.advanced_model;
1535
1820
  const body = {
1536
1821
  export_options: {
1537
1822
  image_format: args.image_format,
@@ -1581,7 +1866,7 @@ server.tool("render_video", "Create a short AI video from either a mockup with a
1581
1866
  // ---------------------------------------------------------------------------
1582
1867
  // Tool 8: get_account
1583
1868
  // ---------------------------------------------------------------------------
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 () => {
1869
+ 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
1870
  const result = await apiRequest({
1586
1871
  method: "GET",
1587
1872
  path: "/api/v1/me",
@@ -1614,10 +1899,11 @@ server.tool("get_account", "Get your account info: subscription plan, remaining
1614
1899
  // 2d_render.failed). Both spellings are subscribable; which one a delivery
1615
1900
  // carries is the endpoint's `event_naming` pin ('current' = family names,
1616
1901
  // 'legacy' = 2d_* names), and the payload's `kind` follows the same pin.
1617
- // Endpoints created before the pin existed are 'legacy'; new endpoints default
1618
- // to 'current' on the API side. The pin is set at create and changed at update;
1619
- // a re-pin sent alone re-spells the stored subscription list to match. The list
1620
- // below is in the API's own order.
1902
+ // Endpoints created before the pin existed are 'legacy'. A create without a pin
1903
+ // takes 'current' only when event_types names photo mockup events by their
1904
+ // family names alone, and 'legacy' otherwise. The pin is set at create and
1905
+ // changed at update; a re-pin sent alone re-spells the stored subscription list
1906
+ // to match. The list below is in the API's own order.
1621
1907
  // ---------------------------------------------------------------------------
1622
1908
  const WEBHOOK_EVENT_TYPES = [
1623
1909
  "render.succeeded",
@@ -1647,7 +1933,7 @@ server.tool("create_webhook_endpoint", "Register a webhook endpoint that SudoMoc
1647
1933
  event_naming: z
1648
1934
  .enum(WEBHOOK_EVENT_NAMINGS)
1649
1935
  .optional()
1650
- .describe("Which spelling of the photo mockup events this endpoint receives: 'current' (photo_mockup.*, photo_mockup_render.*, kind photo_mockup_create/photo_mockup_render) or 'legacy' (2d_mockup.*, 2d_render.*, kind 2d_create/2d_render). Omit to take the API default ('current'). Pick 'legacy' only for a receiver written against the old names."),
1936
+ .describe("Which spelling of the photo mockup events this endpoint receives: 'current' (photo_mockup.*, photo_mockup_render.*, kind photo_mockup_create/photo_mockup_render) or 'legacy' (2d_mockup.*, 2d_render.*, kind 2d_create/2d_render). Omit to let event_types decide: 'current' when it names photo mockup events by their family names only, otherwise 'legacy' (an empty list, a list of events spelled the same either way, or any legacy name). Pick 'legacy' only for a receiver written against the old names."),
1651
1937
  description: z.string().max(255).optional().describe("Optional human-readable label for this endpoint"),
1652
1938
  }, async ({ url, event_types, event_naming, description }) => {
1653
1939
  const body = { url, event_types };
@@ -1780,6 +2066,20 @@ server.tool("replay_webhook_delivery", "Replay a single webhook delivery while p
1780
2066
  }],
1781
2067
  };
1782
2068
  });
2069
+ 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.", {
2070
+ endpoint_id: z.string().describe("The id of the webhook endpoint whose failed deliveries are all sent again (from list_webhook_endpoints)"),
2071
+ }, async ({ endpoint_id }) => {
2072
+ const result = await apiRequest({
2073
+ method: "POST",
2074
+ path: `/api/v1/webhook-endpoints/${endpoint_id}/deliveries/replay-failed`,
2075
+ });
2076
+ return {
2077
+ content: [{
2078
+ type: "text",
2079
+ text: JSON.stringify(publicWebhookBulkReplay(result), null, 2),
2080
+ }],
2081
+ };
2082
+ });
1783
2083
  // ---------------------------------------------------------------------------
1784
2084
  // Local file upload
1785
2085
  //
@@ -1887,6 +2187,242 @@ server.tool("upload_local_file", "Upload a file from this machine and get back a
1887
2187
  }],
1888
2188
  };
1889
2189
  });
2190
+ // The same signing call as upload_local_file, with the upload itself left to the
2191
+ // caller: the upload URL comes back in the result instead of being used here.
2192
+ // It is the tool for a file this process cannot read, and the name the hosted
2193
+ // server publishes for the same step.
2194
+ 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.", {
2195
+ 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."),
2196
+ kind: z
2197
+ .enum(["psd", "artwork"])
2198
+ .default("psd")
2199
+ .describe("\"psd\" for a .psd or .psb template, \"artwork\" for a .png, .jpg, .webp or .gif image."),
2200
+ }, async ({ filename, kind }) => {
2201
+ const result = await apiRequest({
2202
+ method: "POST",
2203
+ path: "/api/v1/uploads/sign",
2204
+ body: { filename, kind },
2205
+ });
2206
+ return {
2207
+ content: [{
2208
+ type: "text",
2209
+ text: JSON.stringify(publicUploadTarget(result), null, 2),
2210
+ }],
2211
+ };
2212
+ });
2213
+ // ---------------------------------------------------------------------------
2214
+ // Family names, added beside the names above
2215
+ // ---------------------------------------------------------------------------
2216
+ // The product names its two kinds of template PSD mockups and photo mockups,
2217
+ // and the tools are spelled that way too. Added, not substituted: a name that
2218
+ // shipped once is answered to forever, because a setup written against it is
2219
+ // not something we can see from here, and a caller who reached for the family
2220
+ // spelling in the window it existed is owed the same. Both spellings are
2221
+ // registered, both are supported, and neither is deprecated.
2222
+ /**
2223
+ * Publish an already-registered tool under a second name.
2224
+ *
2225
+ * The two names share one handler and one input schema: the registration
2226
+ * arguments are replayed with a different name, so there is no second copy to
2227
+ * keep in step and no way for the two to drift apart. The original
2228
+ * registration is not touched, including its description. `relation` is how
2229
+ * the added description names the pairing.
2230
+ */
2231
+ function alsoRegisterAs(existing, secondName, relation = "Family-name spelling") {
2232
+ const source = registrations.get(existing);
2233
+ if (source === undefined || source.method !== "tool") {
2234
+ throw new Error(`Cannot publish ${secondName}: ${existing} has no replayable registration.`);
2235
+ }
2236
+ const [, description, ...rest] = source.args;
2237
+ if (typeof description !== "string") {
2238
+ throw new Error(`Cannot publish ${secondName}: ${existing} was registered without a description, ` +
2239
+ "so replaying its arguments would drop the input schema and take any argument.");
2240
+ }
2241
+ server.tool(secondName, `${description} ${relation} of ${existing}: one tool under two names, same arguments, same behavior. Either name works.`, ...rest);
2242
+ }
2243
+ for (const [existing, familyName] of [
2244
+ // PSD mockups.
2245
+ ["list_mockups", "list_psd_mockups"],
2246
+ ["get_mockup_details", "get_psd_mockup"],
2247
+ ["update_mockup", "update_psd_mockup"],
2248
+ ["delete_mockup", "delete_psd_mockup"],
2249
+ ["render_mockup", "render_psd_mockup"],
2250
+ // Photo mockups.
2251
+ ["create_2d_mockup", "create_photo_mockup"],
2252
+ ["list_2d_mockups", "list_photo_mockups"],
2253
+ ["get_2d_mockup", "get_photo_mockup"],
2254
+ ["update_2d_print_areas", "update_photo_mockup_print_areas"],
2255
+ ["delete_2d_mockup", "delete_photo_mockup"],
2256
+ ]) {
2257
+ alsoRegisterAs(existing, familyName);
2258
+ }
2259
+ // ---------------------------------------------------------------------------
2260
+ // Names the hosted server publishes for tools this package already has
2261
+ // ---------------------------------------------------------------------------
2262
+ // The hosted server and this package offer one set of tools, so a setup moved
2263
+ // from one to the other keeps working. Where the hosted server calls a tool
2264
+ // that exists here by another name, that name is added here the same way the
2265
+ // family names are: the same handler and the same input schema.
2266
+ for (const [existing, secondName] of [
2267
+ ["get_2d_mockup", "get_2d_mockup_details"],
2268
+ ["test_webhook_endpoint", "send_webhook_test_event"],
2269
+ ]) {
2270
+ alsoRegisterAs(existing, secondName, "Second name");
2271
+ }
2272
+ // ---------------------------------------------------------------------------
2273
+ // Tool: render_photo_mockup
2274
+ // ---------------------------------------------------------------------------
2275
+ // The one family name that is not a second name for a single tool. Under the
2276
+ // 2D spelling a render picks its target by picking a tool; under the family
2277
+ // spelling one tool takes both kinds and the target is an argument, which is
2278
+ // the shape this name was published with. It is a dispatcher and nothing more:
2279
+ // the sizing rules, the refusals and the request itself are the same functions
2280
+ // `render_2d_surface` and `render_2d_print_area` call, so there is one
2281
+ // implementation of a 2D render on this side no matter which name reaches it.
2282
+ // The one field the two single-target tools do not offer is blend_mode, which
2283
+ // the hosted server publishes on this tool and on render_2d_mockup.
2284
+ const ONE_TARGET = "Provide exactly one of print_area_uuid or surface_uuid";
2285
+ // Every shared field belongs to this tool too; only the mockup's own argument
2286
+ // is spelled differently, as `mockup_id` -- the name this tool was published
2287
+ // with, and the name the photo mockup tools return the id under. `mockup_uuid`
2288
+ // is accepted beside it (see renderPhotoMockupInput).
2289
+ const { mockup_uuid: _twoDMockupUuid, ...PHOTO_RENDER_SHARED } = TWO_D_SHARED;
2290
+ // The target and the dials that size it. They are the same fields whichever
2291
+ // name the render is reached by, so they are written down once.
2292
+ const PHOTO_RENDER_TARGET = {
2293
+ print_area_uuid: z
2294
+ .string()
2295
+ .optional()
2296
+ .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."),
2297
+ surface_uuid: z
2298
+ .string()
2299
+ .optional()
2300
+ .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."),
2301
+ coverage: z
2302
+ .number()
2303
+ .min(10)
2304
+ .max(100)
2305
+ .optional()
2306
+ .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."),
2307
+ fit: z
2308
+ .enum(FIT_MODES)
2309
+ .optional()
2310
+ .describe("How the artwork meets the print area, which it always fills edge to edge: 'fit' keeps the proportions and fits inside (the default), 'fill' stretches to the edges, 'crop' fills and crops the overflow. 'contain' and 'cover' are the older names for 'fit' and 'crop' and are still accepted. Belongs to print_area_uuid; sending it with surface_uuid is refused. Leave it out to get 'fit'. To sit inside the area with room around it, send width and height instead."),
2311
+ // A percentage cannot express a box whose proportions differ from the
2312
+ // target's, which is exactly what an artwork resized on a canvas is, so
2313
+ // the exact box belongs to both kinds of target.
2314
+ width: z
2315
+ .number()
2316
+ .min(1)
2317
+ .max(30000)
2318
+ .optional()
2319
+ .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."),
2320
+ height: z
2321
+ .number()
2322
+ .min(1)
2323
+ .max(30000)
2324
+ .optional()
2325
+ .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."),
2326
+ };
2327
+ /** The strict input of a one-tool photo mockup render, refusing unknown options by name. */
2328
+ function photoRenderInput(shape) {
2329
+ return z.strictObject(shape, {
2330
+ error: (issue) => {
2331
+ if (issue.code !== "unrecognized_keys")
2332
+ return undefined;
2333
+ return issue.keys
2334
+ .map((key) => key === "scale"
2335
+ ? RETIRED_SCALE
2336
+ : `${key} is not an option on this tool. Send only the options it lists.`)
2337
+ .join(" ");
2338
+ },
2339
+ });
2340
+ }
2341
+ // The blend the hosted server publishes on its one-tool photo mockup render,
2342
+ // under both of its names. Left out, nothing is sent and the API's default,
2343
+ // 'multiply', applies; the values are the ones the API accepts.
2344
+ const PHOTO_BLEND_MODE = {
2345
+ blend_mode: z
2346
+ .enum(["multiply", "normal", "screen", "lighten", "soft_light", "overlay", "darken"])
2347
+ .optional()
2348
+ .describe("How the artwork sits on the product: 'multiply' (the default) keeps the material texture visible, 'normal' reproduces the artwork colors exactly. 'screen', 'lighten', 'soft_light', 'overlay' and 'darken' are also accepted."),
2349
+ };
2350
+ // The mockup travels as `mockup_id`, the name this tool was published with, or
2351
+ // as `mockup_uuid`, the name the hosted server publishes it under. One of the
2352
+ // two is needed; both are accepted when they agree.
2353
+ const renderPhotoMockupInput = photoRenderInput({
2354
+ mockup_id: z
2355
+ .string()
2356
+ .optional()
2357
+ .describe("UUID of the photo mockup (mockup_id from list_photo_mockups, get_photo_mockup or create_photo_mockup). Send this or mockup_uuid."),
2358
+ mockup_uuid: z
2359
+ .string()
2360
+ .optional()
2361
+ .describe("Same as mockup_id, under the name the hosted server uses. Send this or mockup_id; if both are sent they must match."),
2362
+ ...PHOTO_RENDER_SHARED,
2363
+ ...PHOTO_RENDER_TARGET,
2364
+ ...PHOTO_BLEND_MODE,
2365
+ });
2366
+ /** The photo mockup a render_photo_mockup call names, by either spelling. */
2367
+ function photoMockupId(mockupId, mockupUuid) {
2368
+ if (mockupId !== undefined && mockupUuid !== undefined && mockupId !== mockupUuid) {
2369
+ throw new Error("mockup_id and mockup_uuid name the same photo mockup but hold different values. Send one of them.");
2370
+ }
2371
+ const id = mockupId ?? mockupUuid;
2372
+ if (id === undefined) {
2373
+ throw new Error("Provide mockup_id (or mockup_uuid): the UUID of the photo mockup to render.");
2374
+ }
2375
+ return id;
2376
+ }
2377
+ /**
2378
+ * Render one photo mockup target named by argument rather than by tool.
2379
+ *
2380
+ * Shared by every name that takes the target as an argument, so the refusals
2381
+ * and the request are one implementation whichever of them is called.
2382
+ */
2383
+ async function renderNamedTarget(args, mockupUuid) {
2384
+ const hasPrintArea = args.print_area_uuid !== undefined;
2385
+ const hasSurface = args.surface_uuid !== undefined;
2386
+ if (hasPrintArea === hasSurface)
2387
+ throw new Error(ONE_TARGET);
2388
+ // The dial of the other kind of target is refused by name, in the words
2389
+ // the tool that owns that dial already refuses it with. Refusing is keyed
2390
+ // on the argument being written rather than on the value it holds: writing
2391
+ // it is the caller naming the option.
2392
+ if (hasSurface && args.fit !== undefined)
2393
+ throw new Error(TWO_D_REFUSED_OPTIONS.surface.fit);
2394
+ if (hasPrintArea && args.coverage !== undefined) {
2395
+ throw new Error(TWO_D_REFUSED_OPTIONS.print_area.coverage);
2396
+ }
2397
+ const sizing = {
2398
+ ...(args.coverage === undefined ? {} : { coverage: args.coverage }),
2399
+ ...(args.fit === undefined ? {} : { fit: args.fit }),
2400
+ ...(args.width === undefined ? {} : { width: args.width }),
2401
+ ...(args.height === undefined ? {} : { height: args.height }),
2402
+ };
2403
+ assertOneSizingAnswer(sizing, hasSurface ? "coverage" : "fit");
2404
+ return renderTwoD({ ...args, mockup_uuid: mockupUuid }, hasSurface ? { surface_uuid: args.surface_uuid } : { uuid: args.print_area_uuid }, sizing);
2405
+ }
2406
+ server.registerTool("render_photo_mockup", {
2407
+ 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.",
2408
+ inputSchema: renderPhotoMockupInput,
2409
+ }, async (args) => renderNamedTarget(args, photoMockupId(args.mockup_id, args.mockup_uuid)));
2410
+ // ---------------------------------------------------------------------------
2411
+ // Tool: render_2d_mockup
2412
+ // ---------------------------------------------------------------------------
2413
+ // The name the hosted server publishes render_photo_mockup under, and the name
2414
+ // this package's single render tool had before the render was split in two.
2415
+ // It is render_photo_mockup under a second name, with one difference in
2416
+ // spelling: the mockup travels as `mockup_uuid`, as it does on
2417
+ // render_2d_surface and render_2d_print_area and as that name always took it.
2418
+ server.registerTool("render_2d_mockup", {
2419
+ 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.",
2420
+ inputSchema: photoRenderInput({
2421
+ ...TWO_D_SHARED,
2422
+ ...PHOTO_RENDER_TARGET,
2423
+ ...PHOTO_BLEND_MODE,
2424
+ }),
2425
+ }, async (args) => renderNamedTarget(args, args.mockup_uuid));
1890
2426
  // ---------------------------------------------------------------------------
1891
2427
  // Start server
1892
2428
  // ---------------------------------------------------------------------------