<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Martin Pisacreta</title>
    <description>The latest articles on DEV Community by Martin Pisacreta (@martinpisacreta).</description>
    <link>https://dev.to/martinpisacreta</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4195178%2F462e0bce-5293-481a-9f56-31fbf8c0c365.jpg</url>
      <title>DEV Community: Martin Pisacreta</title>
      <link>https://dev.to/martinpisacreta</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/martinpisacreta"/>
    <language>en</language>
    <item>
      <title>Cómo recibo webhooks de Mercado Pago en ASP.NET Core</title>
      <dc:creator>Martin Pisacreta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:54:54 +0000</pubDate>
      <link>https://dev.to/martinpisacreta/como-recibo-webhooks-de-mercado-pago-en-aspnet-core-4j77</link>
      <guid>https://dev.to/martinpisacreta/como-recibo-webhooks-de-mercado-pago-en-aspnet-core-4j77</guid>
      <description>&lt;p&gt;Una notificación de pago puede llegar más de una vez. También puede aparecer cuando mi aplicación ya recibió una actualización más reciente. Si cada entrega dispara otra acreditación, un reintento puede convertirse en un problema de dinero y conciliación.&lt;/p&gt;

&lt;p&gt;Preparé una muestra propia en ASP.NET Core 8 para explorar ese flujo: autenticar la notificación, consultar el pago y actualizar una orden sin repetir trabajo ante una entrega idéntica. Sus ocho pruebas pasan en Windows sobre .NET 8, con la compilación Release sin advertencias ni errores. La muestra utiliza memoria y una API simulada; todavía no demuestra una integración real con Mercado Pago.&lt;/p&gt;

&lt;p&gt;Estos son el recorrido, las comprobaciones y los límites que mantengo visibles antes de adaptar el ejemplo a un negocio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Separar la notificación del estado del pago
&lt;/h2&gt;

&lt;p&gt;Mi endpoint recibe un identificador, pero no toma el estado del pago ni la orden asociada del cuerpo de la notificación. Esos datos deben venir de una consulta autenticada a la API de pagos.&lt;/p&gt;

&lt;p&gt;La interfaz &lt;code&gt;IPaymentsApi&lt;/code&gt; devuelve el identificador del pago, la orden local verificada, el estado y la fecha de actualización. El adaptador debe resolver la referencia del proveedor contra mis registros. Una firma correcta no reemplaza esa comprobación de negocio.&lt;/p&gt;

&lt;p&gt;La muestra no incluye ese adaptador real. Registra una implementación que devuelve un error y responde 503 ante una notificación autenticada. Para usarla con pagos reales, primero tendría que implementar y validar la consulta con credenciales guardadas fuera del código.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validar la entrada antes de procesarla
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;POST /mercadopago/webhook&lt;/code&gt; acepta &lt;code&gt;data.id&lt;/code&gt; desde la consulta o desde un objeto JSON. Si ambas ubicaciones incluyen el identificador, exige que coincidan exactamente. Una discrepancia produce 400 en lugar de elegir silenciosamente uno de los valores.&lt;/p&gt;

&lt;p&gt;También exige un solo encabezado &lt;code&gt;x-request-id&lt;/code&gt; y uno &lt;code&gt;x-signature&lt;/code&gt;. El procesador interpreta una firma con componentes &lt;code&gt;ts&lt;/code&gt; y &lt;code&gt;v1&lt;/code&gt;, rechaza componentes desconocidos o repetidos y comprueba que el resumen hexadecimal tenga la longitud esperada.&lt;/p&gt;

&lt;p&gt;Son reglas concretas de esta implementación. No presento el ejemplo como compatible con cualquier variante de notificación: esa compatibilidad sigue necesitando pruebas con entregas reales.&lt;/p&gt;

&lt;h2&gt;
  
  
  Normalizar el identificador dentro del manifiesto
&lt;/h2&gt;

&lt;p&gt;La firma se calcula sobre un manifiesto, no sobre todo el JSON recibido. En esta muestra, el valor de &lt;code&gt;data.id&lt;/code&gt; se convierte a minúsculas al construir ese texto. El identificador de solicitud conserva sus mayúsculas y minúsculas.&lt;/p&gt;

&lt;p&gt;Este es el primer fragmento de mi archivo &lt;code&gt;Webhooks.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;manifest&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;$"id:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PaymentId&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ToLowerInvariant&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s"&gt;;request-id:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RequestId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;;ts:&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;timestamp&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s"&gt;;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;expected&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;HMACSHA256&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;HashData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;secret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Encoding&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UTF8&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetBytes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;CryptographicOperations&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FixedTimeEquals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;supplied&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;El punto y coma final forma parte del manifiesto. Uso UTF-8, HMAC-SHA256 y comparación de los bytes en tiempo constante. El secreto de webhooks es distinto del token utilizado para consultar pagos.&lt;/p&gt;

&lt;p&gt;La normalización no modifica el identificador que envío al adaptador ni el que guardo en la orden. El octavo caso usa &lt;code&gt;ABC123XYZ&lt;/code&gt;, calcula la firma independientemente con &lt;code&gt;abc123xyz&lt;/code&gt; y conserva &lt;code&gt;MixedCaseRequest8&lt;/code&gt; como identificador de solicitud. Así comprueba que solo se normalice el valor destinado al manifiesto.&lt;/p&gt;

&lt;p&gt;El procesador también exige que la marca de tiempo esté dentro de una tolerancia configurable, de cinco minutos por defecto hacia el pasado o el futuro. El reloj es inyectable para probar ese comportamiento sin depender de la hora de ejecución.&lt;/p&gt;

&lt;h2&gt;
  
  
  Reconocer una repetición exacta
&lt;/h2&gt;

&lt;p&gt;Después de autenticar, el procesador adquiere un semáforo y revisa un conjunto de manifiestos procesados. Este segundo fragmento muestra la salida temprana:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;manifest&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"duplicate"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Una repetición exacta dentro de la ventana válida recibe 200 sin volver a consultar la API. La autenticación ocurre antes: una entrega vencida recibe 401 aunque su manifiesto haya sido procesado previamente.&lt;/p&gt;

&lt;p&gt;Si cambia el identificador de solicitud o la marca de tiempo, cambia la clave y se consulta nuevamente el pago. Eso permite observar actualizaciones posteriores del mismo pago, como el paso de pendiente a aprobado. Deduplicar permanentemente solo por identificador de pago impediría aplicar esa transición.&lt;/p&gt;

&lt;p&gt;Cuando el adaptador falla, respondo 503 sin registrar el manifiesto como procesado. De esa manera, el intento fallido no bloquea una entrega posterior.&lt;/p&gt;

&lt;h2&gt;
  
  
  Evitar que una actualización vieja haga retroceder la orden
&lt;/h2&gt;

&lt;p&gt;Una entrega nueva puede traer una consulta cuyo resultado sea anterior al estado guardado. Comparo la fecha de actualización obtenida de la API, no la hora de llegada del webhook ni la marca de su firma.&lt;/p&gt;

&lt;p&gt;El tercer fragmento contiene las reglas de retroceso:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;stale&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedAt&lt;/span&gt; &lt;span class="p"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UpdatedAt&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;wouldRegress&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"approved"&lt;/span&gt;
        &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="s"&gt;"pending"&lt;/span&gt; &lt;span class="n"&gt;or&lt;/span&gt; &lt;span class="s"&gt;"rejected"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"refunded"&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;payment&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"refunded"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Un resultado anterior o una transición prohibida recibe 200 con &lt;code&gt;ignored_stale_or_regressive&lt;/code&gt;. La orden conserva su estado. Un resultado actual o posterior puede llevar una aprobación a reembolso; una orden reembolsada no vuelve a otro estado con estas reglas.&lt;/p&gt;

&lt;p&gt;La muestra admite un pago por orden. Si aparece otro identificador para una orden ya asociada, responde 409. Es una decisión del ejemplo que habría que revisar para negocios con varios intentos de pago.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué cubren las ocho pruebas
&lt;/h2&gt;

&lt;p&gt;Las ocho pruebas pasan en Windows sobre .NET 8, con la compilación Release sin advertencias ni errores.&lt;/p&gt;

&lt;p&gt;Los casos cubren pago aprobado, firma incorrecta, firma vencida, notificación duplicada, transición de pendiente a aprobado, pago rechazado, aprobación seguida de un resultado pendiente antiguo y normalización del identificador alfanumérico.&lt;/p&gt;

&lt;p&gt;El ejecutor llama directamente al procesador con un reloj fijo y una API falsa que cuenta consultas. El caso duplicado verifica una sola consulta después de dos entregas. Los casos de firma inválida o vencida comprueban que no se consulte la API ni se modifique la orden.&lt;/p&gt;

&lt;p&gt;No se inicia un servidor HTTP ni se realizan llamadas externas. Tampoco hay pruebas específicas de reembolsos, concurrencia distribuida o recuperación después de un reinicio.&lt;/p&gt;

&lt;h2&gt;
  
  
  Qué falta para una acreditación durable
&lt;/h2&gt;

&lt;p&gt;Las órdenes y las claves procesadas viven en memoria. Un reinicio borra ambas, y el conjunto de claves crece sin una política de retención. El semáforo coordina una instancia y mantiene serializadas incluso las consultas al adaptador; no coordina varios procesos.&lt;/p&gt;

&lt;p&gt;Además, actualizar un estado no equivale a acreditar dinero una sola vez. El ejemplo no ejecuta una acreditación contable, un envío ni una transferencia. Para incorporar esos efectos necesitaría almacenamiento durable, restricciones únicas y una transacción que vincule la protección contra duplicados con el cambio de negocio. Las acciones externas requieren su propia estrategia de recuperación.&lt;/p&gt;

&lt;p&gt;Los tres fragmentos pertenecen a mi muestra propia, sin repositorio público. La considero una base verificable para discutir el flujo, con integración real y persistencia todavía pendientes.&lt;/p&gt;

&lt;p&gt;Si necesitas recibir webhooks de Mercado Pago en tu API de ASP.NET Core, escríbeme sobre tu flujo de pagos y los reintentos que necesitas resolver.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>webdev</category>
      <category>spanish</category>
    </item>
    <item>
      <title>How I Protect an ASP.NET Core API with Microsoft Entra ID</title>
      <dc:creator>Martin Pisacreta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:53:35 +0000</pubDate>
      <link>https://dev.to/martinpisacreta/how-i-protect-an-aspnet-core-api-with-microsoft-entra-id-4abc</link>
      <guid>https://dev.to/martinpisacreta/how-i-protect-an-aspnet-core-api-with-microsoft-entra-id-4abc</guid>
      <description>&lt;p&gt;A token can have a valid signature and still belong to another API. A caller can authenticate successfully and still lack permission to write an order. I want those failures to remain separate, both in the implementation and in the HTTP response.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/martinPisacreta/entra-id-api-dotnet" rel="noopener noreferrer"&gt;entra-id-api-dotnet&lt;/a&gt;, a small ASP.NET Core 8 API demonstrating JWT validation and independent scope and role policies. Its 14 integration cases pass in GitHub Actions CI. They exercise the actual JWT middleware with locally signed tokens; they do not establish a working connection to a live Microsoft Entra tenant.&lt;/p&gt;

&lt;p&gt;Here is how I set up that boundary and what the tests actually demonstrate.&lt;/p&gt;

&lt;h2&gt;
  
  
  Start with one tenant and one audience
&lt;/h2&gt;

&lt;p&gt;The application requires two configuration values: &lt;code&gt;Entra:TenantId&lt;/code&gt; and &lt;code&gt;Entra:Audience&lt;/code&gt;. Startup rejects an invalid or empty tenant GUID and a blank audience. The README specifies the API application's client ID GUID as the audience for v2 access tokens.&lt;/p&gt;

&lt;p&gt;I construct the authority from that tenant: &lt;code&gt;https://login.microsoftonline.com/{tenant}/v2.0&lt;/code&gt;. This sample uses a single configured issuer. It does not select a tenant from an incoming token or maintain a list of accepted organizations.&lt;/p&gt;

&lt;p&gt;The registration instructions request v2 access tokens, expose the delegated scope &lt;code&gt;api.read&lt;/code&gt;, and define an application role named &lt;code&gt;Orders.Write&lt;/code&gt;. A separate caller registration receives the appropriate permissions and consent. The API validates incoming credentials; it does not need a client secret to perform that validation.&lt;/p&gt;

&lt;p&gt;The configuration fields start empty. Environment variables can supply their values, allowing deployment settings to stay outside the source tree.&lt;/p&gt;

&lt;h2&gt;
  
  
  Validate before trusting claims
&lt;/h2&gt;

&lt;p&gt;Decoding a JWT is useful for inspection, but the decoded payload is not evidence that its claims are trustworthy. I configure JWT bearer authentication before using those claims in authorization policies.&lt;/p&gt;

&lt;p&gt;This excerpt from &lt;code&gt;Program.cs&lt;/code&gt; shows the validation parameters:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;ValidateIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ValidIssuer&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;issuer&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ValidateAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ValidAudience&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ValidateIssuerSigningKey&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;RequireSignedTokens&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ValidateLifetime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;RequireExpirationTime&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;ClockSkew&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;TimeSpan&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FromSeconds&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="m"&gt;30&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;span class="n"&gt;ValidAlgorithms&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;SecurityAlgorithms&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RsaSha256&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;span class="n"&gt;NameClaimType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"name"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="n"&gt;RoleClaimType&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"roles"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The surrounding configuration requires HTTPS metadata and disables inbound claim mapping. Production authentication uses discovery at the configured authority to obtain signing keys. The API therefore checks the issuer, intended audience, signature and lifetime rather than accepting a payload because it contains a convincing role name.&lt;/p&gt;

&lt;p&gt;The algorithm restriction is explicit: this implementation accepts RS256. The tests use that algorithm too; they do not independently exercise rejection of every other algorithm.&lt;/p&gt;

&lt;p&gt;I also keep the middleware order visible: authentication runs before authorization. Each protected endpoint names its policy, while &lt;code&gt;/health&lt;/code&gt; explicitly allows anonymous access.&lt;/p&gt;

&lt;h2&gt;
  
  
  Match a complete delegated scope
&lt;/h2&gt;

&lt;p&gt;The read endpoint requires &lt;code&gt;api.read&lt;/code&gt;. A scope claim can contain several space-separated permissions, so comparing the entire claim to one string would reject a legitimate combination. Searching for a substring would introduce the opposite problem: &lt;code&gt;api.read.extra&lt;/code&gt; could accidentally satisfy &lt;code&gt;api.read&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;My &lt;code&gt;ApiRead&lt;/code&gt; policy uses this assertion:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAssertion&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;context&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;User&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;FindAll&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"scp"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SelectMany&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claim&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;claim&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sc"&gt;' '&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringSplitOptions&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RemoveEmptyEntries&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Contains&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"api.read"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;StringComparer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Ordinal&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It splits every &lt;code&gt;scp&lt;/code&gt; claim into individual values and performs an exact, case-sensitive comparison. The policy also requires an authenticated user. A scope string alone cannot bypass token validation.&lt;/p&gt;

&lt;p&gt;The tests include a successful token containing &lt;code&gt;profile api.read&lt;/code&gt; and a rejected token containing &lt;code&gt;api.read.extra&lt;/code&gt;. Those cases demonstrate the distinction between a complete permission and a similar-looking value. Case sensitivity is explicit in the implementation; the suite does not include a separate mixed-case input.&lt;/p&gt;

&lt;h2&gt;
  
  
  Give the write endpoint its own policy
&lt;/h2&gt;

&lt;p&gt;The order endpoint requires the role &lt;code&gt;Orders.Write&lt;/code&gt;. With &lt;code&gt;RoleClaimType&lt;/code&gt; configured as &lt;code&gt;roles&lt;/code&gt;, the policy is small:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;AddPolicy&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"OrdersWrite"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;policy&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireAuthenticatedUser&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;RequireRole&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Orders.Write"&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I attach it to &lt;code&gt;POST /orders&lt;/code&gt;, independently of the read policy. A valid caller missing the scope can still satisfy the order policy through its role. A valid caller missing the role can still satisfy the read policy through its scope. Both directions have test cases.&lt;/p&gt;

&lt;p&gt;The README describes configuring the role for application access. The endpoint itself checks authentication and role membership; it does not additionally verify that the token came from a client-credentials flow. The test token builder normally includes both scope and role claims. These tests establish independent permission checks, not a complete distinction between delegated and application-only token types.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make rejection behavior predictable
&lt;/h2&gt;

&lt;p&gt;Missing or invalid credentials produce 401 responses on protected endpoints. The tests also check that these responses include a Bearer authentication challenge.&lt;/p&gt;

&lt;p&gt;A token expired ten minutes earlier receives 401. The configured 30-second clock tolerance means I would not describe expiration as rejection at the exact instant of the timestamp. Boundary timing is not separately tested.&lt;/p&gt;

&lt;p&gt;A different audience, an untrusted signing key and an incorrect issuer also receive 401. An authenticated token without the endpoint's permission receives 403. That distinction helps a caller separate authentication failure from insufficient authorization.&lt;/p&gt;

&lt;p&gt;A token issued by another tenant would not match the single configured issuer. However, the negative test supplies &lt;code&gt;https://untrusted.example/v2.0&lt;/code&gt; as the issuer. It does not obtain a real token from another Entra tenant. The configured restriction is visible in code; cross-tenant integration remains unverified.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 14 cases prove
&lt;/h2&gt;

&lt;p&gt;The suite uses xUnit and &lt;code&gt;WebApplicationFactory&lt;/code&gt; to send HTTP requests through the application. Its factory creates temporary 2048-bit RSA keys, signs test JWTs, and supplies static discovery metadata containing the trusted public key. A separate key generates the untrusted-signature case.&lt;/p&gt;

&lt;p&gt;The factory replaces discovery and signing-key material while preserving the production validation flags, issuer, audience, algorithm restriction and authorization policies. Identity-network requests are explicitly blocked. This avoids making local tests depend on tenant credentials or network availability.&lt;/p&gt;

&lt;p&gt;One parameterized test has 14 inputs covering public health, anonymous requests, valid access, invalid credentials and independent permission failures. All 14 pass in CI at the revision cited below.&lt;/p&gt;

&lt;p&gt;The GitHub Actions workflow runs the Release tests with .NET 8 on Ubuntu, and the run for that revision passed. Live discovery, key rotation, real consent flows and real tenant tokens still need integration validation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the sample's limits visible
&lt;/h2&gt;

&lt;p&gt;The order endpoint returns an acknowledgement; it does not persist orders. These policies also do not implement ownership checks, per-customer data isolation or caller-application allowlists. Those decisions belong beside the relevant business operation.&lt;/p&gt;

&lt;p&gt;All three excerpts come from &lt;a href="https://github.com/martinPisacreta/entra-id-api-dotnet/blob/5f05783815b8f7e727c3f0b7cde723f35788ee63/src/EntraApi/Program.cs" rel="noopener noreferrer"&gt;Program.cs&lt;/a&gt;. The &lt;a href="https://github.com/martinPisacreta/entra-id-api-dotnet/blob/5f05783815b8f7e727c3f0b7cde723f35788ee63/tests/EntraApi.Tests/AuthorizationTests.cs" rel="noopener noreferrer"&gt;tests&lt;/a&gt; and &lt;a href="https://github.com/martinPisacreta/entra-id-api-dotnet/blob/5f05783815b8f7e727c3f0b7cde723f35788ee63/README.md" rel="noopener noreferrer"&gt;README&lt;/a&gt; are pinned to the same revision, &lt;code&gt;5f05783815b8f7e727c3f0b7cde723f35788ee63&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;If you need Microsoft Entra ID authentication in your ASP.NET Core API, write to me about your callers and the permissions you need to enforce.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>security</category>
      <category>azure</category>
    </item>
    <item>
      <title>How I Built a C# MCP Server with Single-Use Order Approvals</title>
      <dc:creator>Martin Pisacreta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 01:02:50 +0000</pubDate>
      <link>https://dev.to/martinpisacreta/how-i-built-a-c-mcp-server-with-single-use-order-approvals-2lih</link>
      <guid>https://dev.to/martinpisacreta/how-i-built-a-c-mcp-server-with-single-use-order-approvals-2lih</guid>
      <description>&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/martinPisacreta/dotnet-mcp-orders-server" rel="noopener noreferrer"&gt;dotnet-mcp-orders-server&lt;/a&gt;, 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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep the tool interface small
&lt;/h2&gt;

&lt;p&gt;I expose three tools: &lt;code&gt;list_orders&lt;/code&gt;, &lt;code&gt;get_order&lt;/code&gt; and &lt;code&gt;cancel_order&lt;/code&gt;. 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.&lt;/p&gt;

&lt;p&gt;The cancellation tool accepts only an order ID. There is no &lt;code&gt;approved&lt;/code&gt; boolean for the assistant to set, and no tool for creating approvals. This is the declaration from &lt;code&gt;OrderTools.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;McpServerTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;Name&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cancel_order"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Cancel a pending fictional order only when an operator has independently recorded an unexpired, single-use approval. This tool cannot create approvals."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="k"&gt;public&lt;/span&gt; &lt;span class="n"&gt;Order&lt;/span&gt; &lt;span class="nf"&gt;CancelOrder&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nf"&gt;Description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Exact ID of the order approved for cancellation."&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;Explain&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Cancel&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Record permission outside the conversation
&lt;/h2&gt;

&lt;p&gt;An operator runs the repository's separate Python approval command, supplying the approval directory and order ID. The command writes an approval containing &lt;code&gt;cancel_order&lt;/code&gt;, the exact order ID and an expiration five minutes in the future.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The server reads its approval directory from &lt;code&gt;ORDERS_APPROVAL_DIRECTORY&lt;/code&gt;, 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  Claim an approval before using it
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;This excerpt from &lt;code&gt;OrderService.cs&lt;/code&gt; shows the claim and validation:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Move&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;source&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="n"&gt;moved&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;true&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;JsonSerializer&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Deserialize&lt;/span&gt;&lt;span class="p"&gt;&amp;lt;&lt;/span&gt;&lt;span class="n"&gt;CancellationApproval&lt;/span&gt;&lt;span class="p"&gt;&amp;gt;(&lt;/span&gt;
    &lt;span class="n"&gt;File&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ReadAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;claimed&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="n"&gt;JsonOptions&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;approval&lt;/span&gt; &lt;span class="k"&gt;is&lt;/span&gt; &lt;span class="n"&gt;not&lt;/span&gt; &lt;span class="k"&gt;null&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Action&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"cancel_order"&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OrderId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;orderId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt;
    &lt;span class="n"&gt;approval&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ExpiresAt&lt;/span&gt; &lt;span class="p"&gt;&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;DateTimeOffset&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;UtcNow&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;A &lt;code&gt;finally&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Before constructing paths, I restrict IDs to 1–64 ASCII letters, digits or hyphens. That prevents inputs such as &lt;code&gt;../outside&lt;/code&gt; from becoming paths under this naming scheme. It does not make an attacker-controlled directory safe.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check business state as well as permission
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The mutation path contains this excerpt:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;!=&lt;/span&gt; &lt;span class="s"&gt;"pending"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Only pending orders can be cancelled."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="p"&gt;(!&lt;/span&gt;&lt;span class="n"&gt;approvals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;TryConsume&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nf"&gt;InvalidOperationException&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"A valid external approval is required. No order was changed."&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;order&lt;/span&gt; &lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="s"&gt;"cancelled"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;orderId&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;cancelled&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That lock coordinates calls within this service instance. It is not a distributed lock or a substitute for database concurrency control.&lt;/p&gt;

&lt;p&gt;Expected failures become &lt;code&gt;McpException&lt;/code&gt; 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the 12 test cases cover
&lt;/h2&gt;

&lt;p&gt;The suite calls &lt;code&gt;OrderTools&lt;/code&gt; directly with a real file approval store in a temporary directory. It does not start a Claude session or exchange MCP messages over stdio.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;The GitHub Actions workflow restores the solution and runs Release tests with .NET 8 on Ubuntu. All 12 tests pass in CI.&lt;/p&gt;

&lt;h2&gt;
  
  
  Boundaries I would preserve in a production design
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;All three C# excerpts are from &lt;a href="https://github.com/martinPisacreta/dotnet-mcp-orders-server/blob/e21057a922624b1e7e1f2275b1cf47b2cc5d71b8/src/OrdersMcp/OrderTools.cs" rel="noopener noreferrer"&gt;OrderTools.cs&lt;/a&gt; and &lt;a href="https://github.com/martinPisacreta/dotnet-mcp-orders-server/blob/e21057a922624b1e7e1f2275b1cf47b2cc5d71b8/src/OrdersMcp/OrderService.cs" rel="noopener noreferrer"&gt;OrderService.cs&lt;/a&gt; at revision &lt;code&gt;e21057a922624b1e7e1f2275b1cf47b2cc5d71b8&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

</description>
      <category>csharp</category>
      <category>dotnet</category>
      <category>mcp</category>
      <category>ai</category>
    </item>
    <item>
      <title>How I Make Stripe Webhooks Idempotent in ASP.NET Core</title>
      <dc:creator>Martin Pisacreta</dc:creator>
      <pubDate>Wed, 28 Oct 2026 00:57:02 +0000</pubDate>
      <link>https://dev.to/martinpisacreta/how-i-make-stripe-webhooks-idempotent-in-aspnet-core-2040</link>
      <guid>https://dev.to/martinpisacreta/how-i-make-stripe-webhooks-idempotent-in-aspnet-core-2040</guid>
      <description>&lt;p&gt;A payment webhook can arrive twice even when nothing is wrong with my application. The uncomfortable case is when the second delivery arrives while the first one is still running. If both requests check whether an event exists before either inserts it, both can decide to apply the payment.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://github.com/martinPisacreta/stripe-webhooks-dotnet" rel="noopener noreferrer"&gt;stripe-webhooks-dotnet&lt;/a&gt; to make those cases reproducible. The sample receives signed Stripe events, stores them in a durable inbox and processes local payment changes in a background worker. Its suite has 38 tests, passing in CI on .NET 8 and .NET 10. Here is the pattern, including the boundaries I would keep visible before adapting it to another API.&lt;/p&gt;

&lt;h2&gt;
  
  
  A retry is another delivery of the same work
&lt;/h2&gt;

&lt;p&gt;An HTTP response can disappear after my server has committed a change. From the sender's perspective, that request failed. Retrying is reasonable, but running the business operation again may be expensive.&lt;/p&gt;

&lt;p&gt;Returning a successful response quickly helps, but it does not make the operation idempotent. Neither does an in-memory collection of event IDs: it disappears on restart and cannot coordinate separate application instances.&lt;/p&gt;

&lt;p&gt;I use the Stripe event ID as the inbox deduplication key. Two deliveries of that ID compete for one database row. This addresses repeated delivery of an event; business rules may also need protection against different events describing the same underlying operation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Verify the original body first
&lt;/h2&gt;

&lt;p&gt;Before trusting the event ID or storing the payload, I verify the signature. Stripe signs the original request body. Deserializing JSON and serializing it again can change whitespace or property ordering, so I keep the received body for verification.&lt;/p&gt;

&lt;p&gt;This excerpt comes directly from &lt;code&gt;WebhookReceiver.cs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="n"&gt;stripeEvent&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="n"&gt;EventUtility&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ConstructEvent&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;signature&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WebhookSecret&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;tolerance&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;settings&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SignatureToleranceSeconds&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;utcNow&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;ToUnixTimeSeconds&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;
    &lt;span class="n"&gt;throwOnApiVersionMismatch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="k"&gt;false&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The receiver limits the body before verification, with a default cap of 1 MB. Signature verification uses a configured tolerance, defaulting to 300 seconds, and an injected clock. Missing or invalid signatures receive a 400 response.&lt;/p&gt;

&lt;p&gt;I check API-version compatibility separately after signature verification. A correctly signed event from an incompatible API release should produce a useful configuration error instead of being mislabeled as a bad signature. Disabling the SDK's combined version exception here does not mean accepting incompatible events.&lt;/p&gt;

&lt;h2&gt;
  
  
  Let the insert decide who wins
&lt;/h2&gt;

&lt;p&gt;The next step is an insert, without a preliminary existence query. &lt;code&gt;ProcessedEvents&lt;/code&gt; has a unique index on &lt;code&gt;EventId&lt;/code&gt;. The receiver uses this statement:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;inserted&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Database&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteSqlInterpolatedAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="s"&gt;$"""
&lt;/span&gt;    &lt;span class="n"&gt;INSERT&lt;/span&gt; &lt;span class="n"&gt;INTO&lt;/span&gt; &lt;span class="nf"&gt;ProcessedEvents&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventId&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventCreated&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;Attempts&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;ReceivedAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;NextAttemptAtUtc&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="n"&gt;stripeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Id&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;stripeEvent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Type&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;created&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="k"&gt;nameof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;)},&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="n"&gt;receivedAt&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
    &lt;span class="n"&gt;ON&lt;/span&gt; &lt;span class="nf"&gt;CONFLICT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;EventId&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;DO&lt;/span&gt; &lt;span class="n"&gt;NOTHING&lt;/span&gt;
    &lt;span class="s"&gt;""",
&lt;/span&gt;    &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The interpolation API supplies database parameters. The SQL conflict clause is SQLite-specific; I would adapt it deliberately when changing database providers.&lt;/p&gt;

&lt;p&gt;One insert wins. A duplicate affects zero rows and receives a 200 response with a duplicate acknowledgement. The winner stores the verified payload as Pending before acknowledging acceptance.&lt;/p&gt;

&lt;p&gt;That ordering is essential. If I acknowledge first and then crash before storage, Stripe has a successful response while my application has lost the work. Here, acceptance means durable receipt; it does not mean the payment handler has finished.&lt;/p&gt;

&lt;h2&gt;
  
  
  Make the queue disposable
&lt;/h2&gt;

&lt;p&gt;After storage, the receiver attempts to enqueue the event ID into a bounded channel. The channel is a scheduling aid. The database remains the durable record.&lt;/p&gt;

&lt;p&gt;If the channel is full, the receiver still acknowledges the stored event. A sweeper finds due Pending rows on startup and periodically afterward, then queues their IDs again. Losing the channel during a restart should delay processing rather than lose accepted events.&lt;/p&gt;

&lt;p&gt;This also means the worker must tolerate receiving the same ID repeatedly. Deduplicating HTTP requests alone would leave a second duplication point inside the application.&lt;/p&gt;

&lt;h2&gt;
  
  
  Commit the claim and payment together
&lt;/h2&gt;

&lt;p&gt;The processor opens a database transaction before claiming an event. Its conditional update is another excerpt from the repository:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight csharp"&gt;&lt;code&gt;&lt;span class="kt"&gt;var&lt;/span&gt; &lt;span class="n"&gt;claimed&lt;/span&gt; &lt;span class="p"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedEvents&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;Where&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;EventId&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;eventId&lt;/span&gt; &lt;span class="p"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt; &lt;span class="p"&gt;==&lt;/span&gt; &lt;span class="n"&gt;EventStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Pending&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ExecuteUpdateAsync&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;set&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;set&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;EventStatus&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Processed&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;SetProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="p"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProcessedAtUtc&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;clock&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;GetUtcNow&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="n"&gt;UtcDateTime&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;cancellationToken&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Zero affected rows means another execution has already moved the event out of Pending. Otherwise, the processor loads the stored payload, invokes the payment handler, saves the changes and commits.&lt;/p&gt;

&lt;p&gt;Although the update sets Processed before the handler runs, that claim remains inside the transaction. If the handler throws, rollback undoes both the claim and the local payment change. A successful commit makes them durable together.&lt;/p&gt;

&lt;p&gt;After failure, the processor records an attempt and schedules a retry with backoff. The defaults allow ten attempts. An exhausted event becomes Failed and needs operational attention: Stripe already received its acknowledgement, so recovery now belongs to my application.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests actually exercise
&lt;/h2&gt;

&lt;p&gt;The suite has 38 tests. The project targets .NET 10 and .NET 8, and CI runs the suite on both runtimes.&lt;/p&gt;

&lt;p&gt;The tests host the application through &lt;code&gt;WebApplicationFactory&lt;/code&gt; with SQLite files and generated webhook secrets. An independent signing helper constructs headers, while a handler wrapper counts calls and injects failures. That gives stronger evidence than checking only the final payment row.&lt;/p&gt;

&lt;p&gt;Coverage includes invalid signatures, duplicate delivery, competing worker claims, rollback, retry exhaustion, recovery of stored Pending events, queue backpressure and stale payment snapshots. The concurrent-delivery case performs ten rounds of 25 parallel requests: 250 requests within that test, rather than 250 separate tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where I draw the boundary
&lt;/h2&gt;

&lt;p&gt;The once-only guarantee concerns local database effects committed with the inbox claim. Sending email, shipping an order or calling another API introduces another failure boundary. I would add an outbox and downstream idempotency before extending that guarantee to external actions.&lt;/p&gt;

&lt;p&gt;SQLite serializes writers. These concurrency tests do not establish SQL Server or PostgreSQL locking behavior, distributed throughput or production capacity. I would validate the chosen provider under representative contention.&lt;/p&gt;

&lt;p&gt;Deduplication also does not solve event ordering. The sample compares event timestamps and includes refund-specific tie handling; those rules need review when adding event types. Failed-event monitoring, replay procedures and payload retention remain operational responsibilities.&lt;/p&gt;

&lt;p&gt;The three excerpts above are from &lt;a href="https://github.com/martinPisacreta/stripe-webhooks-dotnet/blob/53545ae63fa289f5892e6ec57565068910601b78/src/StripeWebhooks.Api/Webhooks/WebhookReceiver.cs" rel="noopener noreferrer"&gt;WebhookReceiver.cs&lt;/a&gt; and &lt;a href="https://github.com/martinPisacreta/stripe-webhooks-dotnet/blob/53545ae63fa289f5892e6ec57565068910601b78/src/StripeWebhooks.Api/Webhooks/EventProcessor.cs" rel="noopener noreferrer"&gt;EventProcessor.cs&lt;/a&gt; at revision &lt;code&gt;53545ae&lt;/code&gt;. I keep that revision explicit so readers can compare the explanation with the implementation.&lt;/p&gt;

&lt;p&gt;If you need idempotent Stripe webhooks in your ASP.NET Core API, write to me about your payment flow and the failure cases you need to handle.&lt;/p&gt;

</description>
      <category>dotnet</category>
      <category>csharp</category>
      <category>stripe</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
