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

Search for a command to run...
Designing tools for LLMs is not designing REST APIs — fewer round trips beats clean orthogonality

No comments yet. Be the first to comment.
ASS karaoke, a fixed mobile anchor, and the one-line bug that highlighted the wrong word.

Both expose usage and cost endpoints. Both have gotchas. Here's the wiring that actually worked.

Digital Craft Workshop - Deep Tech Notes
3 posts
Digital Craft Workshop is where I document the craft of building software. This is the technical wing — production post-mortems, architecture decisions, and AI-native development notes, written for engineers who ship. Subscribe for email-only deep dives.
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 "clean" version is six tools:
update_embed_colors
update_embed_typography
update_embed_layout
update_embed_background
update_embed_form_fields
update_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.
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.
pageBackground isn't a single fieldBackgrounds 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.
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 right id, and send a delta. Replace-on-write means: pass the whole intended state, get the whole intended state. One round trip.
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.
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.
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