# 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](https://drippery.app) — 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.

![](https://cdn.hashnode.com/uploads/covers/61ee9eccc99aed184e727062/a1d9c73d-cef7-4b4a-8626-c95d2527d480.png align="center")

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.

[![](https://cdn.hashnode.com/uploads/covers/61ee9eccc99aed184e727062/47436162-971e-4995-afd9-c477d2220574.png align="center")](https://danielrusnok.substack.com)

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.

```javascript
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.

```javascript
// 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:

```javascript
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](https://blog.digitalcraftworkshop.com/tracking-ai-costs-across-providers-anthropic-and-openai-admin-apis-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](https://modelcontextprotocol.io) — the actual spec, worth reading before you design any tool surface
    
*   [Zod discriminated unions](https://zod.dev/api?id=discriminated-unions) — the API I lean on for shape-varying fields
    
*   [Vercel AI SDK tool calling](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling) — same patterns work outside MCP
    
*   [Drippery](https://drippery.app) — the product the tool drives, if you want to see what 30+ fields produce
    

[![](https://cdn.hashnode.com/uploads/covers/61ee9eccc99aed184e727062/af01ac22-698d-4862-b4a3-d2f607fc8dee.png align="center")](https://digitalcraftworkshop.com)
