{"slug": "how-i-built-a-c-mcp-server-with-single-use-order-approvals", "title": "How I Built a C# MCP Server with Single-Use Order Approvals", "summary": "A developer built dotnet-mcp-orders-server, a .NET 8 MCP server exposing fictional order list, lookup and cancellation tools over stdio, in which cancellation requires an independently recorded, single-use, expiring approval rather than a model-set flag. The approval store claims each approval file by atomic rename before validating action, order ID and expiration, failing closed on malformed or missing approvals, and the project's 12 tests pass in GitHub CI though they do not cover a live client connection.", "body_md": "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.\n\nI built [dotnet-mcp-orders-server](https://github.com/martinPisacreta/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.\n\nThe 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.\n\nI 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.\n\nThe 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`:\n\n```\n[McpServerTool(Name = \"cancel_order\")]\n[Description(\"Cancel a pending fictional order only when an operator has independently recorded an unexpired, single-use approval. This tool cannot create approvals.\")]\npublic Order CancelOrder([Description(\"Exact ID of the order approved for cancellation.\")] string orderId)\n    => Explain(() => orders.Cancel(orderId));\n```\n\nThe 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.\n\nI 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.\n\nAn 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.\n\nIt 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.\n\nThe 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.\n\nReading 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.\n\nThis excerpt from `OrderService.cs` shows the claim and validation:\n\n``` js\nFile.Move(source, claimed);\nmoved = true;\nvar approval = JsonSerializer.Deserialize<CancellationApproval>(\n    File.ReadAllText(claimed), JsonOptions);\nreturn approval is not null &&\n    approval.Action == \"cancel_order\" &&\n    approval.OrderId == orderId &&\n    approval.ExpiresAt > DateTimeOffset.UtcNow;\n```\n\nThe 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.\n\nA `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.\n\nBefore 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.\n\nApproval is only one condition. The order must exist and still be pending. I check those conditions under the service's lock before consuming permission.\n\nThe mutation path contains this excerpt:\n\n```\nif (order.Status != \"pending\")\n    throw new InvalidOperationException(\"Only pending orders can be cancelled.\");\nif (!approvals.TryConsume(orderId))\n    throw new InvalidOperationException(\"A valid external approval is required. No order was changed.\");\nvar cancelled = order with { Status = \"cancelled\" };\norders[orderId] = cancelled;\nreturn cancelled;\n```\n\nThat lock coordinates calls within this service instance. It is not a distributed lock or a substitute for database concurrency control.\n\nExpected 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.\n\nThe 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.\n\nSix 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.\n\nA 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.\n\nThe 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.\n\nThe GitHub Actions workflow restores the solution and runs Release tests with .NET 8 on Ubuntu. All 12 tests pass in CI.\n\nThe 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.\n\nOrders 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.\n\nFor 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.\n\nAll three C# excerpts are from [OrderTools.cs](https://github.com/martinPisacreta/dotnet-mcp-orders-server/blob/e21057a922624b1e7e1f2275b1cf47b2cc5d71b8/src/OrdersMcp/OrderTools.cs) and [OrderService.cs](https://github.com/martinPisacreta/dotnet-mcp-orders-server/blob/e21057a922624b1e7e1f2275b1cf47b2cc5d71b8/src/OrdersMcp/OrderService.cs) at revision `e21057a922624b1e7e1f2275b1cf47b2cc5d71b8`.\n\nIf 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.", "url": "https://wpnews.pro/news/how-i-built-a-c-mcp-server-with-single-use-order-approvals", "canonical_source": "https://dev.to/martinpisacreta/how-i-built-a-c-mcp-server-with-single-use-order-approvals-2lih", "published_at": "2026-10-28 01:02:50+00:00", "updated_at": "2026-10-28 01:11:15.752765+00:00", "lang": "en", "topics": ["ai-agents", "agent-protocols", "ai-tools", "ai-safety", "developer-tools"], "entities": ["dotnet-mcp-orders-server", ".NET 8", "Model Context Protocol", "GitHub", "Claude", "ChatGPT", "martinPisacreta"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/how-i-built-a-c-mcp-server-with-single-use-order-approvals", "markdown": "https://wpnews.pro/news/how-i-built-a-c-mcp-server-with-single-use-order-approvals.md", "text": "https://wpnews.pro/news/how-i-built-a-c-mcp-server-with-single-use-order-approvals.txt", "jsonld": "https://wpnews.pro/news/how-i-built-a-c-mcp-server-with-single-use-order-approvals.jsonld"}}