I shipped one MCP tool that updates 30+ fields, and I'm not splitting it
Designing tools for LLMs is not designing REST APIs — fewer round trips beats clean orthogonality

I built an MCP tool that takes 30+ optional fields. The textbook says I should have built six. Here's why I'm keeping it.
Drippery — my email drip tool — has an embed. It's a customizable subscribe form that you drop into a Substack post or a landing page. The embed has thirty-plus knobs: colors, fonts, layout, OG image, and custom form fields.
When I added an MCP tool so I could configure all of that from Claude, I made it one tool. Not six. This goes against how I'd design a REST API, and I'd do it again.
The temptation to split
The "clean" version is six tools:
update_embed_colorsupdate_embed_typographyupdate_embed_layoutupdate_embed_backgroundupdate_embed_form_fieldsupdate_embed_metadata
Each tool has a focused schema. Each does one thing. Easy to document. Easy to test in isolation. This is good API design.
For an LLM, it's a bad tool design.
When I tell Claude:
make the subscribe form for sequence X have a dark gradient background, switch the heading to Inter, and add a company-name field
The six-tool version means the model has to plan a sequence:
call colors,
then typography,
then the background,
then form fields.
Each call has its own validation, failure mode, and latency. If one fails halfway, you get a half-applied embed.
The version I shipped
One tool. Everything optional. One round trip.
const updateEmbedSchema = z.object({
embed_id: z.string().optional(),
sequence_id: z.string().optional(),
primaryColor: z.string().optional(),
secondaryColor: z.string().optional(),
// ... 25+ more optional fields
customFields: z.array(customFieldSchema).max(5).optional(),
pageBackground: pageBackgroundSchema.optional(),
});
The handler reads which fields are defined and only writes those. Undefined means "leave as-is." Partial updates on every property — the schema does the work, the handler stays dumb.
The hard part: pageBackground isn't a single field
Backgrounds can be a solid color, a gradient, or an image. Each type has different sub-fields. The wrong move: one field per type.
// don't do this
pageBackground: {
solidColor: z.string().optional(),
gradientColors: z.array(z.string()).optional(),
gradientDirection: z.string().optional(),
imageUrl: z.string().optional(),
imageOverlay: overlaySchema.optional(),
}
Now the LLM has to know that solidColor and gradientColors are mutually exclusive, and the validator can't help.
The right thing is a discriminated union:
const pageBackgroundSchema = z.discriminatedUnion("type", [
z.object({ type: z.literal("solid"), color: z.string() }),
z.object({
type: z.literal("gradient"),
colors: z.array(z.string()).min(2),
direction: z.enum(["to-r", "to-b", "to-br", "to-bl"]),
}),
z.object({
type: z.literal("image"),
url: z.string().url(),
overlay: overlaySchema.optional(),
}),
]);
The direction field stores just the suffix; the renderer prepends bg-gradient-to- (Tailwind v3) when emitting CSS.
Now the validator enforces the shape per branch. The LLM picks type and the rest is constrained. Claude rarely gets this wrong — the schema rules wrong calls out.
The array trap
customFields is an array of up to five form fields (text inputs, checkboxes). I made it replace-on-write, not merge. If you send customFields: [{...}], the new list replaces the old one entirely.
Merge semantics for arrays sound nice until you ask, 'Merge by what key?'
Position? Then deleting a field means sending the array minus one.
Some kind of
id? Then the LLM has to fetch the current state first, find the rightid, and send a delta. Replace-on-write means: pass the whole intended state, get the whole intended state. One round trip.
One tool, two lookup keys
And one more: dual lookup. The tool accepts embed_id OR sequence_id. Each Drippery sequence has at most one embedding (one-to-one). Most of the time the agent has the sequence_id from a different conversation, and forcing it to first call get_embed_by_sequence to translate is one wasted round trip.
The handler resolves whichever is provided and prefers embed_id if both are set. Three lines of code, zero documentation overhead, one fewer round trip per real call. Same instinct as when I tracked AI costs across providers in one Next.js page instead of one dashboard per vendor — collapse the trip count, even when the schema gets uglier.
What I'd take from other MCP tools
Designing for an LLM caller, not a human REST consumer:
Optional everywhere, partial updates by default. Required fields are friction. Validate semantics in the handler when you need to, but let the schema accept the partial.
Discriminated unions over flat optional fields when the shape varies. The validator enforces what you'd otherwise put in a comment.
Replace, don't merge for collections. Round-trip cost beats Delta Gymnastics.
Multiple lookup keys when the calling context might have any of them.
Round-trip count is the metric. Splitting tools cleanly is for humans reading docs. LLMs care about how many calls it takes to express an intent.
The Drippery update_embed The tool shipped as one commit. Claude can rebuild the whole subscribe form in one call. The schema is verbose. That's fine — verbosity in the type, the protocol stays small.
What I read while figuring this out
Model Context Protocol specification — the actual spec, worth reading before you design any tool surface
Zod discriminated unions — the API I lean on for shape-varying fields
Vercel AI SDK tool calling — same patterns work outside MCP
Drippery — the product the tool drives, if you want to see what 30+ fields produce





