Skip to main content

Command Palette

Search for a command to run...

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

Updated
5 min read
I shipped one MCP tool that updates 30+ fields, and I'm not splitting it

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_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.

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 right id, 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

1 views

More from this blog

D

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.