Giving an assistant a tool for reading orders is straightforward. Giving it a cancellation tool raises a different question: who authorizes the change? I wanted to explore that boundary without treating a model-generated confirmation as permission.
I built dotnet-mcp-orders-server, a small .NET 8 MCP server exposing fictional orders. It offers list, lookup and cancellation tools over stdio. Cancellation requires an independently recorded approval for one action and one exact order, with an expiration time.
The project has 12 tests, all passing in GitHub CI. Those tests exercise the tool methods and approval behavior; they do not establish an interactive connection with Claude or ChatGPT. Here is how I built the approval flow and where its guarantees end.
Keep the tool interface small
I expose three tools: list_orders, get_order and cancel_order. The first two read seeded, in-memory data. The third changes a pending order to cancelled. No external order system or payment provider is involved.
The cancellation tool accepts only an order ID. There is no approved boolean for the assistant to set, and no tool for creating approvals. This is the declaration from OrderTools.cs:
[McpServerTool(Name = "cancel_order")]
[Description("Cancel a pending fictional order only when an operator has independently recorded an unexpired, single-use approval. This tool cannot create approvals.")]
public Order CancelOrder([Description("Exact ID of the order approved for cancellation.")] string orderId)
=> Explain(() => orders.Cancel(orderId));
The description helps the assistant understand the contract. Enforcement lives in the service and approval store, so it still applies if a client ignores that description.
I use the MCP SDK's stdio transport and register the tool class with dependency injection. Application logs go to stderr because stdout is reserved for protocol messages. Mixing ordinary console output into that stream can break communication before business logic even runs.
Record permission outside the conversation
An operator runs the repository's separate Python approval command, supplying the approval directory and order ID. The command writes an approval containing cancel_order, the exact order ID and an expiration five minutes in the future.
It writes a temporary file in the destination directory, flushes it, and replaces the final JSON file. That avoids publishing partially written JSON during an ordinary update. I would not turn that helper into another model-accessible tool: doing so would let the caller manufacture the permission it needs.
The server reads its approval directory from ORDERS_APPROVAL_DIRECTORY, falling back to a directory beside the assembly. In a real setup, choosing that location and controlling who can write there are part of the authorization design. A path alone authenticates nobody.
Claim an approval before using it
Reading an approval and deleting it afterward would leave a race: two callers could both read the same permission before either removes it. My store first renames the file to a unique claimed name.
This excerpt from OrderService.cs shows the claim and validation:
File.Move(source, claimed);
moved = true;
var approval = JsonSerializer.Deserialize<CancellationApproval>(
File.ReadAllText(claimed), JsonOptions);
return approval is not null &&
approval.Action == "cancel_order" &&
approval.OrderId == orderId &&
approval.ExpiresAt > DateTimeOffset.UtcNow;
The rename is the point where competing consumers contend for the same source file. Validation then checks the action, order and expiration. A malformed document, inaccessible file or missing approval fails closed.
A finally block attempts to delete the claimed file after validation, including when validation rejects its contents. The store does not put an expired or malformed approval back into circulation. If cleanup fails, the claimed file may remain, but its name is no longer the approval filename consumed by later requests.
Before constructing paths, I restrict IDs to 1–64 ASCII letters, digits or hyphens. That prevents inputs such as ../outside from becoming paths under this naming scheme. It does not make an attacker-controlled directory safe.
Check business state as well as permission
Approval is only one condition. The order must exist and still be pending. I check those conditions under the service's lock before consuming permission.
The mutation path contains this excerpt:
if (order.Status != "pending")
throw new InvalidOperationException("Only pending orders can be cancelled.");
if (!approvals.TryConsume(orderId))
throw new InvalidOperationException("A valid external approval is required. No order was changed.");
var cancelled = order with { Status = "cancelled" };
orders[orderId] = cancelled;
return cancelled;
That lock coordinates calls within this service instance. It is not a distributed lock or a substitute for database concurrency control.
Expected failures become McpException messages at the tool boundary. A caller can distinguish missing approval from a missing order or an order that cannot be cancelled. I want the assistant to explain the rejection instead of repeatedly submitting the same forbidden operation.
What the 12 test cases cover
The suite calls OrderTools directly with a real file approval store in a temporary directory. It does not start a Claude session or exchange MCP messages over stdio.
Six individual cases cover listing seeded orders, reading an order, rejecting an unknown ID, rejecting cancellation without approval, consuming a valid approval and rejecting cancellation of a shipped order.
A parameterized test adds four invalid-approval cases: expired permission, a different order, a different action and malformed JSON. Another adds two unsafe-ID cases. That makes 12 executed cases, including the parameterized inputs.
The successful cancellation test checks the returned state, reads the changed order back and verifies that the approval directory is empty. It then creates a fresh service and confirms that the consumed approval cannot authorize another cancellation.
The GitHub Actions workflow restores the solution and runs Release tests with .NET 8 on Ubuntu. All 12 tests pass in CI.
Boundaries I would preserve in a production design
The approval directory must remain outside the model's writable filesystem and shell access. This sample does not establish an operating-system security boundary against an agent running as the same user with arbitrary shell access.
Orders reset on restart, while unused approval files can survive. I document removing outstanding approvals before restarting a session. Approval consumption and the in-memory state change are not a durable transaction: a crash between them can consume permission without completing cancellation.
For production, I would need durable order state, authenticated operator approval, audit records and explicit crash recovery. I would also add transport-level and client integration tests. The supplied Claude setup examples are configuration instructions, not evidence that an interactive connection succeeded.
All three C# excerpts are from OrderTools.cs and OrderService.cs at revision e21057a922624b1e7e1f2275b1cf47b2cc5d71b8.
If you need an MCP server with externally approved actions in your API, write to me about your workflow and the permissions you need to enforce.
Top comments (0)