Read tools can embarrass you. Write tools can hurt you. A model that misreads a ticket gives a bad answer; a model that misreads a ticket and then closes it, replies to the customer and reassigns it has done real work that someone now has to undo.
That doesn't mean MCP servers shouldn't write. It means write tools need a different design than read tools. Here are the rules I follow, ending with the most useful new feature of the 2026 spec: asking the user before acting.
Rule 1: read-only is the default
Every new tool starts as read-only, and becomes a write tool only when there's a clear need. Mark it honestly:
[McpServerTool(Name = "get_ticket", ReadOnly = true)]The annotations (ReadOnly, Destructive, Idempotent) are hints to the client, and some clients use them to decide what to ask the user about. They're not a security boundary, because a client can ignore them. But they're free, and they make your intentions visible to everyone reading the tool list.
Then put write tools in their own class with [Authorize], as in the previous post, so only people who may change data can even see them.
Rule 2: no tool you'd be scared to see called by mistake
Some tools shouldn't exist at all. My list:
- No hard deletes. If tickets can be deleted, the MCP server doesn't get that tool. Closing or archiving is enough, and it can be undone.
- No bulk actions. "resolve_all_tickets_matching" turns one bad model decision into five hundred. One record per call.
- No free-form writes. No "update_ticket(string json)". Each write tool does one named change with typed parameters.
If an action can't be undone, a person does it in your normal app. The agent can prepare it, as we'll see.
Rule 3: writes must be safe to repeat
Models retry. Clients retry. Networks drop responses after the work was done. So every write tool should give the same result when called twice with the same input.
For resolve_ticket that's easy: if the ticket is already resolved, say so and change nothing. For send_reply in the eleventh post, deleting the draft after sending made a second call fail cleanly instead of emailing the customer twice. Ask for each write tool: what happens if this exact call arrives twice? If the answer is "something bad", fix that before anything else.
Rule 4: ask the user, in the protocol, not in the description
A description like "Only use this after the user confirms" is a request to the model, and models don't always follow requests. Real confirmation has to come from the user, not from the model's judgment.
The 2026-07-28 spec added a clean way to do this, called Multi Round-Trip Requests. A tool can answer "I need input first" instead of a result. The client shows the question to the user, then calls the same tool again with the answer. It works with stateless servers because nothing waits on the server between the two calls.
Here's resolve_ticket built that way:
[McpServerTool(Name = "resolve_ticket", Destructive = false, Idempotent = true)]
[Description("""
Marks a ticket as resolved and records how it was fixed.
The user is asked to confirm, and can edit the note, before anything changes.
""")]
public async Task<string> ResolveTicket(
McpServer server,
RequestContext<CallToolRequestParams> context,
[Description("The ticket number in the format SD-1234.")]
string ticketNumber,
[Description("One or two sentences explaining what fixed the problem.")]
string resolutionNote,
CancellationToken cancellationToken)
{
var ticket = await tickets.GetAsync(ticketNumber, cancellationToken)
?? throw new McpException(
$"Ticket {ticketNumber} doesn't exist. Use search_tickets to find it.");
if (ticket.Status == TicketStatus.Resolved)
{
return $"Ticket {ticketNumber} is already resolved. Nothing changed.";
}
// Second round: the client calls again with the user's answer.
if (context.Params?.InputResponses?.TryGetValue("confirm", out var response) is true)
{
var answer = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo);
if (answer?.IsAccepted is not true)
{
return $"The user did not confirm. Ticket {ticketNumber} was not changed.";
}
var finalNote =
answer.Content?.TryGetValue("resolutionNote", out var edited) is true
&& !string.IsNullOrWhiteSpace(edited.GetString())
? edited.GetString()!
: resolutionNote;
await tickets.ResolveAsync(ticketNumber, finalNote, cancellationToken);
return $"Ticket {ticketNumber} resolved.";
}
// First round: nothing has changed yet. Ask before acting.
if (!server.IsMrtrSupported)
{
throw new McpException(
"Resolving a ticket needs the user's confirmation, and this client can't ask for it. " +
$"Tell the user to resolve {ticketNumber} in the SupportDesk app.");
}
throw new InputRequiredException(
inputRequests: new Dictionary<string, InputRequest>
{
["confirm"] = InputRequest.ForElicitation(new ElicitRequestParams
{
Message = $"Resolve {ticketNumber} \"{ticket.Title}\"? You can edit the note first.",
RequestedSchema = new()
{
Properties =
{
["resolutionNote"] = new ElicitRequestParams.StringSchema
{
Title = "Resolution note",
Default = resolutionNote
}
}
}
})
},
requestState: ticketNumber);
}A few decisions in there are worth calling out.
The user sees what will be written. The model's note is the default value, and the user can change it. The confirmation isn't just "are you sure?", it's "is this what you mean?".
Everything is checked again on the second round. Between the question and the answer, a colleague might have resolved the ticket. So the tool loads the ticket and checks its status on every call. The second call comes from the client with the same arguments, which makes this natural.
requestState isn't trusted. It travels through the client and comes back, so treat it as data from outside. Here it carries nothing the tool relies on; all real decisions come from the database.
It fails closed. Older clients that don't support the round trip get a clear error, not a silent write. That's the right default for a write tool: if you can't ask, don't act. For tools that must work everywhere, the draft-then-send pattern from the eleventh post is the fallback that every client supports.
One honest limit: the confirmation is shown by the client. It protects the user from the model acting on its own. It doesn't protect you from a malicious client that fakes an "accept". That's what authentication and roles from the previous post are for. The two solve different problems, and you need both.
Test the "no" path
The happy path gets tested by accident. The "no" path doesn't. With the in-memory setup from the twelfth post, give the test client an elicitation handler and cover three cases:
- The user accepts with an edited note: the ticket is resolved with the edited note, not the model's.
- The user declines: nothing changes, and the result says so.
- The ticket was already resolved: no question is asked at all.
If those three pass, the tool behaves well in the situations that actually cause incidents.
Summary
Default to read-only. Leave out deletes and bulk actions. Make every write safe to repeat. And for anything that matters, let the user confirm in the protocol, re-check everything when the answer comes back, and refuse to act when you can't ask.
Want the full build? Build MCP Servers in C#: Connect AI to Your .NET Apps shows you how to give AI access to your own .NET apps with an MCP server. Or get the weekly tips by email: /newsletter.



