MCP in Production · Chapter 6

Tools that Compose

One of my early MCP servers had nineteen tools. Every one of them worked in isolation. I tested them by hand, the way you'd test any API: punch in inputs, eyeball outputs, check the boxes. I shipped it to a customer and watched Claude pick up the server and try to do something useful with it.

The model called the right tool for step one. Got back a wall of JSON. Stared at it for a beat. Then called the same tool again with slightly different parameters. And again. Eventually it gave up and told the user it wasn't sure how to proceed.

The tools were correct. The surface was unusable.

That was the moment I learned that "tool design" in MCP is not API design. It is choreography. You are not designing endpoints for a programmer who will read your docs, take notes, and sketch out a flow on a whiteboard. You are designing moves for a model that has roughly one shot to figure out the next step from whatever your last tool returned. If the output of search_thing does not slot cleanly into the input of get_thing, you have not built two tools. You have built two dead ends with a comma between them.

This chapter is about designing tool surfaces that survive the agentic loop. We will walk through the patterns that compose, the patterns that look composable but quietly aren't, and a four-tool flow from @yawlabs/aws-mcp that ties it all together. By the end you should have a checklist you can apply to your own server before you publish it and watch a model thrash on it in front of a paying customer.

The agentic loop is the whole job

Every MCP server lives inside the same loop:

  1. The model picks a tool based on the user's request and whatever is in context.
  2. The tool runs and returns a result.
  3. The model reads the result and picks the next tool.
  4. Repeat until the model has enough to answer the user, or it gives up.

That loop has a few properties worth pinning down before we get into design. First, the model only sees what your tool returns. Not what your tool computed internally, not what's in your database, not what you wrote in your README. The return value is the entire bridge from one step to the next. Second, the model has finite context, and every tool call's output sits in that context for the rest of the loop. A tool that returns 80KB of JSON on a list query has just torched a chunk of the budget you needed for the rest of the workflow. Third, the model is doing a kind of greedy planning. It does not have your wiki open in another tab. If the path forward is not visible in the last result plus the tool list, the model is going to invent something or stall.

Designing for this loop means designing for legibility at the join points. The output of step N has to make the choice at step N+1 obvious. Not possible -- obvious. There is a giant gap between "the necessary information is technically present in the response" and "the model will reliably extract it and use it correctly." That gap is where most first-draft tool surfaces live.

The way I think about it now: I am not designing tools, I am designing the trajectory the model is going to take through them. If I cannot trace a clean line from the user's intent through three or four tool calls and back to a useful answer, the surface is wrong, no matter how clean any individual tool looks.

Output shape becomes input shape

The single most important pattern in MCP tool design is this: the things your read tools emit must be the things your other tools consume.

Concretely: if you have a search_orders tool, it should return order objects (or at minimum order IDs) in a format that is the literal input shape for get_order, update_order, and cancel_order. Not a "result item" wrapper. Not a { orders: [...] } envelope wrapped in { data: ..., meta: ... }. The IDs that come out of one tool need to flow into the next tool with zero transformation in the model's head.

Here is the version that does not compose:

ts// search_orders returns this shape { results: [ { type: "order", attributes: { order_number: "ORD-1234", customer: { id: "cust_abc", name: "Acme" }, status: "shipped" } } ], pagination: { page: 1, total: 47 } } // get_order takes this shape get_order({ orderId: string })

The model now has to figure out that attributes.order_number is what goes into orderId. Sometimes it does. Sometimes it passes the whole result object. Sometimes it tries cust_abc because that was the last thing it saw with "id" in the key name. You will see this in the wild and it will look like the model is "being dumb." It is not being dumb. Your shape lied about which field was the identity.

Here is the version that composes:

ts// search_orders returns this shape { orders: [ { orderId: "ORD-1234", customerName: "Acme", status: "shipped" } ], nextPageCursor: "eyJwYWdlIjoyfQ==" } // get_order takes this shape get_order({ orderId: string })

The field names match. orderId comes out, orderId goes in. The model does not have to translate. The model is now spending its reasoning budget on the user's actual problem instead of on plumbing.

I run a test for this on every server before I ship: I look at every read-shaped tool and ask, "if the next tool the model wants to call needs the IDs from this output, can it copy them straight across?" If the answer is "yes after a small transformation," the answer is no. There is no such thing as a small transformation when a model is doing it under context pressure on the seventh tool call of a long session.

The corollary is that you should be ruthless about what your read tools emit. Every field you add is either load-bearing for the next decision the model has to make, or it is noise. Customer billing addresses on a search result are noise unless the next step is going to ship something. Internal version numbers on a list view are noise. Computed fields like last_modified_human ("3 hours ago") are noise -- the model can compute that itself if it cares, and it usually doesn't. Every byte you add to a list response is a byte the model has to wade through to find the field that mattered.

Pagination as a composability problem

Pagination is where well-meaning APIs go to die in MCP. The REST instinct is to expose ?page=2&pageSize=50&sortBy=createdAt&sortDir=desc&filter=... and let the caller assemble the query. That is the right design when the caller is a human with autocomplete, query inspector tabs, and the patience to read response headers. It is the wrong design when the caller is a model that has to guess your defaults.

The problem with rich pagination parameters in MCP is that they pollute the parameter space. Every pagination knob is a parameter the model has to consider on every call, even when the user just asked "find me the order from yesterday." The model now has to decide: do I pass pageSize: 100? sortBy: createdAt? Will the default be wrong? It will pick something. Often it picks plausibly. Sometimes it picks pageSize: 1 because that was an example in your docstring, and now your three-result query returns one result and a "more available" hint, and the model goes off chasing pagination instead of answering the question.

The pattern that works: opaque cursors and a single nextCursor field in the response.

ts// Tool input list_orders({ status?: "shipped" | "pending" | "cancelled"; cursor?: string; // opaque, returned by previous call }) // Tool output { orders: [...], nextCursor: "eyJwYWdlIjoyfQ==" | null // null when no more pages }

The model only sees a cursor parameter, and the only legal value for it is something the server itself returned on a previous call. There is no decision to make about page size. There is no decision to make about sort order. The server picks reasonable defaults (page size somewhere between 20 and 100 depending on the size of an item), and if the model wants the next page, it copies the cursor in. If the response says nextCursor: null, the model knows to stop. No ambiguity. No parameter explosion.

If you genuinely need user-tunable page size or sort -- and you usually don't, in agentic contexts -- expose them as separate, optional parameters with defaults that work. But understand what you're paying. Every optional parameter doubles the surface the model has to reason about. The agentic context rewards tools that look small from the outside even when they are doing real work on the inside.

The deeper reason cursors win: they make pagination state opaque to the model. The model does not have to remember that it just saw page 2. It does not have to compute page 3. It just passes the cursor it got back and trusts the server. This is the same principle as opaque session tokens in web auth -- you push the state into the token so the caller does not have to manage it. Models are extraordinarily bad at managing state across tool calls. Anything you can do to take state off their plate, you should.

List then detail: cheap list, expensive detail

The second pattern that comes out of context pressure: list operations should be cheap, detail operations should be where the heavy data lives.

Consider an MCP server for an issue tracker. The naive design: search_issues returns full issue bodies, comment threads, attachments, label histories, the works. The thinking is "the model has it all, no second call needed." In practice, this surface chokes the moment the user asks "find issues mentioning the word 'auth.'" The model now has 40 issues' worth of comment threads in context and still has to figure out which one is relevant.

The pattern that works: list_issues returns a thin summary -- ID, title, status, maybe a one-line description -- and get_issue returns the full body. The model lists, reads the summaries, picks the one that matches, and pulls the detail for just that one. Total context used is dramatically less, and the model's job is much clearer at every step. Pick a candidate from a thin list, then pull the heavy detail on the candidate.

ts// Cheap. Always cheap. list_issues({ status?: "open" | "closed", cursor?: string }) // returns: { issues: [{ issueId, title, status, summary }], nextCursor } // Expensive. Only called when the model has narrowed down. get_issue({ issueId: string }) // returns: { issueId, title, body, comments: [...], labels: [...], ... }

The output shape of list_issues is designed so the model can scan it and immediately see which issue to pull. Title and a one-line summary do most of the work; the model is good at picking the right one from a list of titles. The full body is one tool call away when the model commits.

A useful mental model: think of list_X as the table of contents and get_X as the chapter. You would not print the entire book in the table of contents. The same logic applies to your tool surface, but the cost of getting it wrong is paid in token budget and in the model's ability to reason about the result.

The trap to avoid: do not put a "give me everything" parameter on list_X. If you let the model pass includeFullBody: true to your list tool, the model will eventually pass it. Probably on a 40-result query. Probably right after the user asks an open-ended question. Keep the cheap operation cheap. If the model needs the full body, it can call the detail tool. That is what the detail tool is for.

Idempotency: the model retries

Models retry. They retry when a tool errors. They retry when they think a tool errored but it actually didn't. They retry when the response shape didn't match their expectations and they think the call must have failed. They retry when context gets compacted and they forget they already did the thing.

If your write tools are not idempotent, the model will silently double-bill, double-create, double-send your customers' emails. This is not a hypothetical. I have watched a model call create_invoice twice in a row because the first call's response had a slightly weird envelope and the model decided it had failed. The customer got two invoices.

Idempotency in MCP looks like this:

  1. Every write operation accepts a client-supplied idempotency key (or you derive one deterministically from the inputs).
  2. The server tracks recent keys and short-circuits duplicates with the same response shape as the original success.
  3. The TTL on the key store is long enough to cover any plausible retry window. A few minutes at minimum. A few hours is safer.
tscreate_invoice({ customerId: string, lineItems: LineItem[], idempotencyKey: string // model passes a UUID or hash })

When the model retries, it passes the same key (if you tell it to in the tool description) or you derive the same key from the inputs. The second call returns the same invoice as the first. No duplicate. The model proceeds.

A subtle variant: tools that do not have a natural idempotency key but are conceptually safe to retry. Updates that use absolute values (set_status: "shipped") are idempotent by construction -- calling twice is the same as calling once. Updates that use deltas (increment_quantity: 1) are not. Prefer absolute updates over deltas in tool inputs whenever you can. The model will retry. The data should not care.

For genuinely non-idempotent operations -- sending an email, charging a card, transferring money -- you want explicit confirmation in the loop. We will get to that in "Reads versus writes" below.

A last note on idempotency: think of it as the property that makes the model's mistakes recoverable. Without it, every retry is a chance for a duplicate. With it, the worst case of a confused model is a tool call that does nothing. That asymmetry is the whole game.

Reads versus writes: naming, hints, confirmation

MCP gives you a few primitives for distinguishing safe operations from dangerous ones, and you should use them all.

The first one is naming. A reader scanning your tool list -- whether that reader is the model or a human reviewing the trace -- should know within a couple of words whether a tool reads or writes. list_resources, get_resource, search_resources are all clearly reads. create_resource, update_resource, delete_resource, restart_resource are clearly writes. Avoid clever names. A tool named process_order could be doing anything; a tool named mark_order_shipped cannot. Be boring on purpose.

The second one is the annotations on the tool definition itself. The protocol gives you readOnlyHint, destructiveHint, idempotentHint, and openWorldHint. These are not just metadata for documentation. Hosts use them to decide whether to prompt the user for confirmation, whether to allow a tool in a "safe mode" session, and whether the model can call the tool without an explicit user approval each time. Setting them correctly is part of being a good citizen on the protocol.

tsserver.registerTool( "list_resources", { description: "List AWS resources of a given type", inputSchema: { /* ... */ }, annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: true, // calls the AWS API }, }, async (args) => { /* handler */ }, ); server.registerTool( "delete_resource", { description: "Delete a resource by ID. This action is irreversible.", inputSchema: { /* ... */ }, annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, // calling delete twice = still deleted openWorldHint: true, // calls the AWS API }, }, async (args) => { /* handler */ }, );

Set readOnlyHint: true only on tools that genuinely cannot modify any state, including caches you care about. Set destructiveHint: true on anything that is hard or impossible to undo -- deletes, force-pushes, payment captures, customer-facing emails. Be honest with these flags. If you flag a destructive tool as non-destructive because you want to skip confirmation prompts, you have built a footgun and aimed it at your users.

The third primitive is the confirmation pattern. For genuinely dangerous operations, the right design is often a two-tool dance: one tool that prepares and previews the change, one tool that commits it. The preview returns a token (a change ID, a plan ID, whatever you want to call it) along with a description of exactly what would happen. The commit tool takes the token and executes. The model presents the preview to the user, gets explicit approval, then commits.

ts// Step 1: model prepares the change plan_resource_modification({ resourceId: string, changes: Record<string, unknown> }) // returns: { planId: "plan_abc", summary: "Will change X from A to B; will restart 1 instance" } // Step 2: model surfaces the plan to the user, gets approval, then: apply_resource_modification({ planId: "plan_abc" }) // returns: { applied: true, resultingState: {...} }

The plan is just a record of intent. Plans expire after some short TTL, so a stale approval doesn't apply to a no-longer-valid change. The host can show the plan summary to the user verbatim, which is much safer than asking the user to eyeball a JSON blob of arguments. And critically, the model cannot accidentally skip the confirmation -- the only way to apply a change is with a plan ID, and the only way to get a plan ID is to call the plan tool first.

This pattern is more verbose than a single modify_resource tool. It is also the pattern that has saved my customers from production incidents more than once. For anything that touches money, customer data, or production infrastructure, plan-then-apply is worth the extra round trip.

Sampling: when the right answer is not "another tool"

There is a temptation, when you start designing tool surfaces, to make every capability into a tool. Need to summarize a long document? Add a summarize tool. Need to classify some text? Add a classify tool. Need to extract structured data? Add an extract tool.

Stop. The protocol has a primitive for this: sampling. Sampling is the capability that lets your server ask the host's LLM to do work. It is the inverse of the normal flow. Normally the model calls your tool. With sampling, your tool calls back to the model, asks it a question (often with a specific prompt and a constrained response shape), and uses the answer.

Use sampling, not a tool, when:

Use a tool, not sampling, when:

The thing that goes wrong when people overuse tools-as-LLM-calls is that you end up with two model calls in series for a single language task: the host model decides to call your summarize tool, your tool calls some other model with a hardcoded prompt, you return the summary, the host model uses it. You have introduced latency, a second billing relationship, and a divergence point where your hardcoded model is doing something the host's model could have done in line for free. Worse, you have probably hardcoded a worse model than the host is using -- the user picked the host model on purpose.

If you find yourself reaching for an LLM SDK inside your MCP server's tool handler, stop and ask whether sampling is what you actually want. Most of the time, it is.

The macro tool anti-pattern

Closely related: the urge to write a "macro tool" that internally chains several other tools your server already exposes. Something like:

ts// DO NOT DO THIS deploy_and_notify({ service: string, version: string, notifyChannel: string }) { await this.tools.deploy(service, version); await this.tools.notify(notifyChannel, `Deployed ${service}@${version}`); }

The argument for this is that it's easier on the model -- one tool call instead of two. The argument is wrong, and here's why.

When the model calls a macro tool, it commits to the entire chain before it sees any intermediate result. If deploy fails, the model gets back an error and has no way to know whether notify ran. If deploy succeeds with a warning that should change the notify message, the macro has already sent the boilerplate notify. The model has lost the ability to react to results, which is exactly the loop the protocol exists to enable. You have replaced an agentic flow with a hardcoded one, and you have hidden the hardcoding inside a tool name.

There is a second cost: macro tools rot. The relationship between deploy and notify is application-specific. Some users want to notify Slack, some want PagerDuty, some want email, some want all three. The moment you bake "deploy and notify" into a single tool, you have to start adding parameters to cover every variation. Six months later you have deploy_and_notify with eleven optional parameters, half of which only apply if other parameters are set, and the model is failing to figure out which combination is valid for the current context. Meanwhile the underlying deploy and notify tools, used independently, would have composed cleanly.

The model is good at chaining tools when each tool has a clear shape and the outputs feed into the inputs. That is the whole point of the agentic loop. Don't take that capability away from the model by pre-composing flows for it. Expose the primitives. Let the model compose.

The exception, narrowly: when a sequence is genuinely atomic and the user can never want to do half of it. transfer_funds(from, to, amount) is not "withdraw + deposit" because a partial failure between the two would be a financial incident. That is one operation, even if the implementation is two database writes. But deploy + notify is two operations the user might absolutely want to do separately. The atomicity test is "does anyone reasonable ever want only the first half?" If yes, two tools.

Cross-server composition

The really interesting composition story in MCP isn't within one server. It's across them. A user installs @yawlabs/aws-mcp and @yawlabs/lemonsqueezy-mcp and expects to be able to ask "for every customer who upgraded to the Pro tier this week, spin up a dedicated EC2 instance tagged with their customer ID."

That request requires the host model to:

  1. Call the lemonsqueezy server's list_subscriptions (filtered to recent upgrades).
  2. Pull customer IDs out of those results.
  3. Call the AWS server's create_instance for each customer, passing the customer ID as a tag.

Cross-server composition works when each server's read tools emit IDs and identifiers that mean something outside that server. Customer IDs from billing should be the same strings you'd use as tags in AWS. If lemonsqueezy.list_subscriptions returns a customer object with id: "cust_abc123" but also customer_email: "user@example.com", the model has options for what to thread through, and the user's mental model probably uses email anyway.

The principle: design your tool outputs as if other servers' tools are going to consume them. You don't know which other servers. You don't know which fields they'll key off of. So expose the obvious identifiers -- the email, the customer ID, the order number, the SKU, the resource ARN -- as flat top-level fields, not buried inside nested objects. Make the cross-server join as easy as "this string came out of A, paste it into B."

Anti-patterns that break cross-server composition:

This is partly why I am picky about field names across the @yawlabs servers. A customer ID is customerId everywhere. A resource identifier is resourceArn if it's an AWS ARN, full stop -- not arn, not resourceId. Consistency across servers is not aesthetic -- it's the substrate that lets cross-server composition happen at all.

A four-tool flow on @yawlabs/aws-mcp

Let me walk through a real composition. The scenario: a user asks Claude, "the api-gateway resource in our staging cluster has been throwing 5xx all morning. Can you bump its memory to 1GB and let me confirm before applying?"

The aws-mcp server exposes (among others) these four tools:

tslist_resources({ resourceType?: string, cluster?: string, cursor?: string }) // returns: { resources: [{ resourceArn, resourceType, name, status, cluster }], nextCursor } describe_resource({ resourceArn: string }) // returns: { resourceArn, resourceType, name, configuration: {...}, currentMetrics: {...} } plan_resource_modification({ resourceArn: string, changes: Record<string, unknown> }) // returns: { planId, summary, before, after, estimatedDowntime } apply_resource_modification({ planId: string }) // returns: { applied, appliedAt, newConfiguration }

The model's trajectory on the user's request:

Step 1. Model reads "api-gateway in staging" and calls list_resources({ cluster: "staging" }). It does not know the exact ARN yet -- the user gave it a name. The list returns thin summaries:

json{ "resources": [ { "resourceArn": "arn:aws:ecs:us-east-1:...:service/staging/api-gateway", "resourceType": "ecs-service", "name": "api-gateway", "status": "running", "cluster": "staging" }, { "resourceArn": "arn:aws:ecs:us-east-1:...:service/staging/auth-service", "resourceType": "ecs-service", "name": "auth-service", "status": "running", "cluster": "staging" } ], "nextCursor": null }

The model picks the api-gateway entry. Note that the ARN comes out of list_resources ready to feed into the next tool. No translation. No "extract the arn from the metadata wrapper." The output shape was designed to feed forward.

Step 2. Model calls describe_resource({ resourceArn: "arn:aws:ecs:..." }) with the ARN it just got. Now it gets the full configuration:

json{ "resourceArn": "arn:aws:ecs:...:service/staging/api-gateway", "resourceType": "ecs-service", "name": "api-gateway", "configuration": { "memory": "512", "cpu": "256", "desiredCount": 2, "image": "..." }, "currentMetrics": { "errorRate5xx": 0.08, "memoryUtilization": 0.94 } }

The model now has confirmation that memory utilization is 94% (likely cause of the 5xx) and that current memory is 512MB. The user wants 1GB. The model has everything it needs to plan the change.

Step 3. Model calls plan_resource_modification({ resourceArn: "...", changes: { memory: "1024" } }). The server doesn't apply the change -- it constructs a plan:

json{ "planId": "plan_7a3b9c", "summary": "Update memory from 512 to 1024 on api-gateway in staging. Will trigger rolling deploy of 2 tasks. Estimated downtime: 0s (rolling update).", "before": { "memory": "512" }, "after": { "memory": "1024" }, "estimatedDowntime": "0s" }

The model surfaces this summary to the user verbatim. The user reads it, approves. The plan ID is opaque -- the model does not have to construct it, just thread it through.

Step 4. Model calls apply_resource_modification({ planId: "plan_7a3b9c" }). The server validates the plan is still fresh (planId hasn't expired, underlying state hasn't changed since the plan was generated), applies the change, returns:

json{ "applied": true, "appliedAt": "2026-04-29T15:32:11Z", "newConfiguration": { "memory": "1024", "cpu": "256", "desiredCount": 2 } }

The model reports back to the user: change applied, here's the new config.

Notice what each tool earned its keep on:

The output of step N is the input of step N+1. Every time. The ARN comes out of list, goes into describe, goes into plan. The plan ID comes out of plan, goes into apply. The model never has to construct an ARN from parts. It never has to assemble a plan summary from a config diff. The work is in the right places; the legibility is at the joins.

If apply_resource_modification fails because the planId expired (because the user took too long to approve), the model gets a clear error and re-runs plan_resource_modification. No partial state. No "did the change apply or not?" The plan-then-apply pattern is idempotent at the level of intent, even when the underlying operation isn't.

This is what tool composition looks like when you do it right. Boring at the seams, exciting in what it lets the user accomplish.

A checklist for your own server

Before I publish a new server -- and before I ship a new version of an existing one -- I run through this list:

  1. For every read tool, do its outputs match the input shape of the related write or detail tools? If search_X returns id but update_X takes xId, fix the field names so they match.
  2. For every list tool, is the response under a few KB for typical queries? If not, move heavy data to a detail tool.
  3. For every write tool, is it idempotent? Either by construction (absolute updates), by an idempotency key, or by a plan-then-apply pattern. Pick one.
  4. Is every dangerous tool flagged with destructiveHint: true and named in a way that makes the danger obvious to a human reading the tool list?
  5. Is every read-only tool flagged with readOnlyHint: true?
  6. Does pagination use opaque cursors, not page numbers? Does the response have a single nextCursor field that's null when done?
  7. Are IDs flat top-level fields, not nested inside wrapper objects? Are they the same identifiers the upstream system uses?
  8. Are there any tools that internally chain other tools on this server? If yes, can you split them and let the model do the chaining?
  9. Are there any tools that just call an LLM with a fixed prompt? If yes, can the protocol's sampling capability replace them?
  10. If a user installs your server alongside two other popular servers, will the IDs and identifiers compose? Run through a hypothetical cross-server flow in your head.

The list is not exhaustive. It is the set of things that have bitten me. Most of them I learned by shipping a server, watching a model fail to use it the way I expected, and tracing back to the design choice that made the failure inevitable.

What composition really buys you

Tools that compose are cheap to add to. You can ship five well-designed tools and the model will assemble flows you never explicitly designed. You did not write a "find recently upgraded customers and provision EC2 instances for them" tool. You wrote list_subscriptions and create_instance and let the customer IDs flow between them. The user gets value from the combination because the pieces compose, not because you anticipated the use case.

Tools that don't compose require you to anticipate every flow. The moment the user wants something you didn't pre-build, they hit a wall. Your server is a closed catalog of capabilities, not a substrate. You will spend the rest of the server's life adding new tools to cover gaps that wouldn't have been gaps if the original tools had composed.

The investment in tool design pays off slowly at first and then all at once. The first few tools feel like extra work -- why am I worrying about cursor opacity on a server with three tools? -- and then you cross some threshold where new tools just slot in and start working with the existing ones, and the model starts doing things you didn't expect. That's the moment you know the surface is right.

The next chapter is about the other half of this story: handling errors and partial failures gracefully when those compositions go wrong. Because they will. The agentic loop is robust to a lot of failures, but only if your server speaks the protocol's error shapes correctly and gives the model enough information to recover. We'll get into what good error responses look like, when to retry server-side versus when to surface the failure, and how to handle the long tail of "the upstream API returned a weird thing" cases that every production server eventually has to deal with.