diff --git a/CHANGELOG.md b/CHANGELOG.md index 77b4fba..ac00bf0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to **BuildingBlocks** packages in this repository are docume The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## BuildingBlocks.Mcp [1.1.0] - 2026-09-19 + +### Added + +- **Distributed write idempotency** (`UseDistributedIdempotency` + `UseRedisLock` / `RedisMcpIdempotencyLock`): wait-and-replay across instances. Completed payloads live on the host `IDistributedCache` (`mcp:idemp:{tool}\u001f{clientKey}`); in-flight work is serialized with a SET NX PX lease (lock key `{payloadKey}:lock`) using the host `IConnectionMultiplexer`. Waiters poll and replay; they do not get HTTP Processing/409 while the lease is valid. Resolving `IMcpInvoker` fails if the lock is missing (never execute unlocked). Custom `IMcpIdempotencyLock` remains supported. The package does not reference `BuildingBlocks.Idempotency`. +- **2026 MRTR confirmation:** when the client advertises MCP `2026-07-28` (`IsMrtrSupported`), an unconfirmed `RequireConfirmation` write becomes SDK `InputRequiredException` / `resultType: input_required` elicitation for `confirmed` (`requestState` `awaiting-confirmation`, opaque echo — not a server session). Accept sets `McpInvokeContext.Confirmed` and invokes; decline returns `ConfirmationRequired` without invoking. MCP `2025-11-25` clients still receive `ConfirmationRequired` JSON. `confirmed: true` skips elicitation on both revisions. + +### Notes + +- Distributed mode is **not** exactly-once. The default **2-minute lease** is an in-flight safety window with **no renewal**. Lease expiry can overlap executions. A crash before Set, or a Set failure after a successful invoke, can cause another execution (at-least-once). Wait-budget exhaustion (`AcquireWaitBudget`, default 30 seconds) returns MCP `Conflict`; the client should retry (replay if Set completed). Queries never use the store or lock. +- Memory `UseMemoryIdempotency` is unchanged (process `SemaphoreSlim` wait-and-replay). +- Drop-in for hosts that stay on memory idempotency and `confirmed: true`. Lab default host remains memory; distributed Redis is a `WithWebHostBuilder` overlay using `RedisMcpIdempotencyLock`. + ## BuildingBlocks.Pagination.EntityFrameworkCore [1.1.0] - 2026-09-04 ### Added diff --git a/README.md b/README.md index 5b7762d..3f8a08b 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,7 @@ Formerly [FeatureManagement](https://github.com/Maxofpower/FeatureManagement) (G ## Table of contents - [BuildingBlocks](#buildingblocks) + - [In-repo (not on NuGet)](#in-repo-not-on-nuget) - [How they work together](#how-they-work-together) - [BuildingBlocks.Mediator](#buildingblocksmediator) - [BuildingBlocks.Mcp](#buildingblocksmcp) @@ -59,7 +60,7 @@ NuGet packages you can install in **your** hosts. The FeatureFusion API is a sho | Package | Version | Role | TFMs | |---------|---------|------|------| | **[BuildingBlocks.Mediator](https://www.nuget.org/packages/BuildingBlocks.Mediator)** | **1.1.0** | CQRS **Send** + ordered pipeline (`ICommand` / `IQuery`, typed behaviors, opt-in traces + metrics) | net8 / net9 / net10 | -| **[BuildingBlocks.Mcp](https://www.nuget.org/packages/BuildingBlocks.Mcp)** | **1.0.0** | Message types → MCP tools on the official SDK (deny-by-default, `McpResult`, HTTP + opt-in stdio) | net8 / net9 / net10 | +| **[BuildingBlocks.Mcp](https://www.nuget.org/packages/BuildingBlocks.Mcp)** | **1.1.0** | Message types → MCP tools on the official SDK (deny-by-default, `McpResult`, HTTP + opt-in stdio; distributed wait-and-replay idempotency, 2026 MRTR confirmation) | net8 / net9 / net10 | | **[BuildingBlocks.Idempotency](https://www.nuget.org/packages/BuildingBlocks.Idempotency)** | **1.0.1** | HTTP **Idempotency-Key** — MVC + Minimal API, 2xx envelope replay, ProblemDetails, optional Redis lock, fingerprint, ActivitySource | net8 / net9 / net10 | | **[BuildingBlocks.Pagination.EntityFrameworkCore](https://www.nuget.org/packages/BuildingBlocks.Pagination.EntityFrameworkCore)** | **1.1.0** | Typed keyset (cursor) pagination for EF Core (IR bundled): any-width Npgsql row comparison, `NULLS FIRST/LAST`, `HasKeysetIndex` + `NullOrder` | net8 / net9 / net10 | | **[BuildingBlocks.Telemetry](https://www.nuget.org/packages/BuildingBlocks.Telemetry)** | **1.0.2** | Config-driven OpenTelemetry (traces, metrics, logs) + `IntegrateMediator` / opt-in `IntegrateMcp` | net8 / net9 / net10 | @@ -67,6 +68,18 @@ NuGet packages you can install in **your** hosts. The FeatureFusion API is a sho Production apps use **Mediator + Telemetry** and export OTLP to any backend. SigNoz hosting is **local AppHost only**. +### In-repo (not on NuGet) + +These BuildingBlocks live in the same solution and are used by the lab. They are **project-reference only** — not published to nuget.org. Do not treat the NuGet table above as the full catalog. + +| Project | Role | TFMs | +|---------|------|------| +| **[BuildingBlocks.Domain](src/BuildingBlocks/Domain/AGENTS.md)** | Focused DDD primitives: `Entity`, `AggregateRoot`, `ValueObject`, typed `Identity` / `AggregateId` / `EntityId`, `IBusinessRule` | net8 / net9 / net10 | +| **[BuildingBlocks.Domain.EntityFrameworkCore](src/BuildingBlocks/Domain.EntityFrameworkCore/AGENTS.md)** | EF Core converters for Domain identities and value objects (`HasIdentityConversion` / `HasValueObjectConversion`) | net8 / net9 / net10 | +| **[BuildingBlocks.Pagination.Dapper](src/BuildingBlocks/Pagination.Dapper/AGENTS.md)** | Same keyset IR as the EF package, over `IDbConnection` (`QueryCursorPageAsync`). Lab/dev adapter — not a nupkg | net8 / net9 / net10 | + +Pagination IR (`BuildingBlocks.Pagination`) is **bundled into** `BuildingBlocks.Pagination.EntityFrameworkCore`. Do not pack IR or Dapper separately. Domain is ready as a sibling; it has not earned a nuget.org boundary yet. + ### How they work together ```mermaid @@ -235,6 +248,10 @@ await sender.Send((object)new CreateOrder("SKU-1", 2), ct); // MCP / dynamic Map **application message types** (commands, queries, DTOs) and **public static Minimal API methods** to MCP tools. The official C# SDK owns the protocol; this package owns the catalog, `McpResult`, filters, and safe defaults. **Not** OpenAPI, **not** MVC controllers (unsupported for now), **not** a SOLID linter. +**What's new in 1.1.0:** +- **Distributed write idempotency** — `UseDistributedIdempotency` + `UseRedisLock` (wait-and-replay across instances; not HTTP Processing/409). Default 2-minute lease is a safety window with **no renewal**, not exactly-once. Wait-budget exhaustion is MCP `Conflict` (client retries). MCP keys `mcp:idemp:…` are distinct from HTTP `Idempotency_*`. Custom `IMcpIdempotencyLock` still works. This package does not reference `BuildingBlocks.Idempotency`. +- **2026 MRTR confirmation** — unconfirmed `RequireConfirmation` writes elicit `confirmed` via SDK `InputRequiredException` when the client is MCP `2026-07-28`. Accept invokes; decline does not. `2025-11-25` still returns `ConfirmationRequired` JSON. + ```bash dotnet add package BuildingBlocks.Mcp ``` @@ -336,7 +353,7 @@ MCP has no HTTP verb on Mediator messages. **Command ≈ POST/PUT**; **Query ≈ | Schema | `string` + `format: uuid` (hint; host accepts any non-empty string, including ULID) | no key property | | Opt out | `Idempotent = false` (lab `demo.echo`) | — | -Register a store with `o.UseMemoryIdempotency(ttl)` (single instance). Multi-instance: implement `IMcpIdempotencyStore` (Redis, etc.). Keys are namespaced per tool; in-flight calls share a lock; success is replayed as `JsonElement`. The library never retries writes. Cursor/Claude fill `idempotencyKey` from the tool schema (they do not inject a key unless it is required). Reuse the same UUID only when retrying the same write. `RequireConfirmation` adds required `confirmed: true`. +Register a store with `o.UseMemoryIdempotency(ttl)` (single instance, process wait-and-replay). Multi-instance: `o.UseDistributedIdempotency().UseRedisLock()` (host `IDistributedCache` + `IConnectionMultiplexer`; wait-and-replay; 2-minute lease is a safety window, not exactly-once; **no renewal** in 1.1.0). Custom `IMcpIdempotencyLock` instead of `UseRedisLock` is still allowed. Cache Get/Set without a lock is not enough. Wait-budget exhaustion (`AcquireWaitBudget`, default 30 seconds) is MCP `Conflict` — not HTTP 409 Processing; the client should retry. MCP payload keys (`mcp:idemp:…`) are distinct from HTTP `Idempotency_*`. Queries never use this mechanism. The library never retries writes. Cursor/Claude fill `idempotencyKey` from the tool schema (they do not inject a key unless it is required). Reuse the same UUID only when retrying the same write. `RequireConfirmation` still requires `confirmed: true`; MCP `2026-07-28` clients get elicitation, `2025-11-25` stays `ConfirmationRequired` JSON. Cursor HTTP: @@ -362,7 +379,7 @@ Cursor HTTP: [![NuGet](https://img.shields.io/nuget/v/BuildingBlocks.Idempotency.svg?logo=nuget)](https://www.nuget.org/packages/BuildingBlocks.Idempotency) [![GitHub Release](https://img.shields.io/github/v/release/Maxofpower/FeatureFusion?filter=idempotency-v*&logo=github&label=GitHub%20Release)](https://github.com/Maxofpower/FeatureFusion/releases?q=idempotency-v) -ASP.NET Core HTTP **Idempotency-Key** for MVC and Minimal API. Host-owned `IDistributedCache`, **2xx** envelope replay, ProblemDetails on conflicts, optional Redis SET NX lock, opt-in method/path/body fingerprint, per-endpoint TTL, optional ActivitySource. Distinct from MCP write idempotency (`UseMemoryIdempotency` / `IMcpIdempotencyStore` above). +ASP.NET Core HTTP **Idempotency-Key** for MVC and Minimal API. Host-owned `IDistributedCache`, **2xx** envelope replay, ProblemDetails on conflicts, optional Redis SET NX lock, opt-in method/path/body fingerprint, per-endpoint TTL, optional ActivitySource. Distinct from MCP write idempotency (`UseMemoryIdempotency` / `UseDistributedIdempotency` + `IMcpIdempotencyLock` above). **What's new in 1.0.1:** NuGet package icon; **System.Text.Json** for cache envelope and MVC `ObjectResult` capture (dropped Newtonsoft.Json). No API surface change from 1.0.0. @@ -716,6 +733,7 @@ Install the packages above in your own hosts, **or** clone this repo and run **F | Area | What you get | |------|----------------| | Mediator (CQRS) | **`BuildingBlocks.Mediator`** — used by FeatureFusion handlers | +| Domain | **`BuildingBlocks.Domain`** + **`Domain.EntityFrameworkCore`** — aggregates / identities (in-repo, not packed) | | MCP | **`BuildingBlocks.Mcp`** — opt-in tools (`[McpTool]` on types/methods or `MapTool`) at `/mcp` | | Telemetry | **`BuildingBlocks.Telemetry`** in ServiceDefaults; **`BuildingBlocks.Aspire.Hosting.SigNoz`** on AppHost | | Event bus | RabbitMQ + transactional outbox/inbox, DLQ, dedup hooks | @@ -983,6 +1001,7 @@ See [Pagination showcase](#pagination-showcase) for the FeatureFusion catalog (` |---------|-------------------| | **Mediator / CQRS** | `BuildingBlocks.Mediator` — `ICommand`/`IQuery` Send + pipeline; host handlers in FeatureFusion | | **CQRS** | `Features/.../Commands` + `Queries` with dedicated handlers | +| **DDD primitives** | `BuildingBlocks.Domain` — `Entity` / `AggregateRoot` / `ValueObject` / typed ids (in-repo, not packed) | | **Void command** | `ICommand : ICommand` — concrete type in pipeline (no Adapter / `IRequest`) | | **Decorator** | Pipeline behaviors; EventBus handler decorators in tests | | **Singleton** | Cached mediator wrappers / long-lived Redis multiplexer | @@ -991,7 +1010,7 @@ See [Pagination showcase](#pagination-showcase) for the FeatureFusion catalog (` | **Unit of work** | `ResilientTransaction` spanning business write + outbox | | **Strategy** | Feature filters & validation styles (endpoint filter vs ValidationBehavior) | | **Template method** | `BaseValidator.PostInitialize` | -| **Keyset pagination** | `BuildingBlocks.Pagination.EntityFrameworkCore` — typed bidirectional cursors | +| **Keyset pagination** | `BuildingBlocks.Pagination.EntityFrameworkCore` — typed bidirectional cursors; Dapper adapter is in-repo only | | **Chain of Responsibility** | Feature toggle rule evaluation; mediator pipeline chain | | **Observer / messaging** | RabbitMQ integration events (outbox → bus → handlers) | | **Outbox / Inbox** | `TransactionalOutbox` + `OutBoxWorker` | @@ -1039,9 +1058,10 @@ dotnet test FeatureFusion.sln -c Release | Project | Notes | |---------|--------| +| `BuildingBlocks.Domain.Tests` | DDD primitives (in-repo; not a nupkg) | | `BuildingBlocks.Mediator.Tests` | Package suite on **net8 / net9 / net10** | | `BuildingBlocks.Mediator.Analyzers.Tests` | BBM001 / BBM002 | -| `BuildingBlocks.Mcp.Tests` | Catalog, invoker, endpoint methods, MapTool scoped SP, idempotency, filters | +| `BuildingBlocks.Mcp.Tests` | Catalog, invoker, endpoint methods, MapTool scoped SP, memory + distributed idempotency, 2026 MRTR protocol HTTP, filters | | `BuildingBlocks.Mcp.Analyzers.Tests` | BBMCP001–005 | | `BuildingBlocks.Pagination.Tests` | Codec, registry, identifiers (net8 / net9 / net10) | | `BuildingBlocks.Pagination.EntityFrameworkCore.Tests` | Sqlite keyset + shadow + projection; Postgres Testcontainers when Docker is available | diff --git a/docs/building-blocks/MCP_TEST_MATRIX.md b/docs/building-blocks/MCP_TEST_MATRIX.md index 2d125aa..6438811 100644 --- a/docs/building-blocks/MCP_TEST_MATRIX.md +++ b/docs/building-blocks/MCP_TEST_MATRIX.md @@ -17,6 +17,9 @@ xUnit on **net8.0 / net9.0 / net10.0**. No coverlet gate. CI: `.github/workflows | Aspire live HTTP | `FeatureFusionMcpTests` — tools/list (`demo.echo`, `products.list`, `orders.create`, `lab.ping`), echo, orders.create, products.list schema, `structuredContent`, catalog://tools, `lab.ping` | | Cursor HTTP | `src/.cursor/mcp.json` → `http://localhost:5141/mcp`; API must be running (see [`mcp.md`](mcp.md)) | | Idempotency | Commands only; `UseMemoryIdempotency`; missing key; store prevents double dispatch; namespaced keys; TTL; in-flight lock; `JsonElement` replay; queries ignore store | +| Distributed idempotency | `UseDistributedIdempotency` + `UseRedisLock` / `RedisMcpIdempotencyLock` (or custom `IMcpIdempotencyLock`); wait-and-replay (not HTTP 409); shared store+lock one handler; concurrent same key; completed replay; handler throw no Set; abandoned/expired lease; cancel releases; lease-expiry overlap characterized; wait-budget MCP Conflict; wrong-owner Release; acquire throw fail-closed; cache Get/Set failures; different keys; query/unconfirmed skip store+lock; confirmed then replay; negative cache-only two handlers; memory wait-and-replay preserved | +| Redis lock | `RedisMcpIdempotencyLock`: acquire, contention, lease expiry, wrong-owner release, canceled token, Redis error, `UseRedisLock` DI | +| 2026 MRTR / confirmation | Protocol HTTP: `2026-07-28` unconfirmed → `input_required` elicitation; accept invokes; decline `ConfirmationRequired` without invoke; `requestState` echo; `2025-11-25` stays `ConfirmationRequired` JSON; `confirmed: true` skips elicitation | | Rate limit | Deny → `RateLimited` | | Confirm / timeout | ConfirmationRequired; Timeout | | Filter | Hidden from list and invoke | diff --git a/docs/building-blocks/cookbook.md b/docs/building-blocks/cookbook.md index 9b217b1..141e627 100644 --- a/docs/building-blocks/cookbook.md +++ b/docs/building-blocks/cookbook.md @@ -167,7 +167,7 @@ api.MapPost("/items", CreateItem) ### Idempotency -Commands ≈ POST/PUT; queries ≈ GET. Store: `UseMemoryIdempotency(ttl)`. Schema `format: uuid`; runtime any non-empty string. `Idempotent = false` to opt a command out. Queries never use the store. Multi-instance: `IMcpIdempotencyStore`. Lab: `orders.create` (key + `confirmed`), `demo.echo` (opt-out), `lab.ping` (query). +Commands ≈ POST/PUT; queries ≈ GET. Store: `UseMemoryIdempotency(ttl)` (single process, wait-and-replay) or `UseDistributedIdempotency` + `UseRedisLock` (farms; host Redis multiplexer; wait-and-replay; 2-minute lease is not exactly-once; **no renewal**; wait-budget exhaustion is MCP `Conflict`, not HTTP 409). Custom `IMcpIdempotencyLock` instead of `UseRedisLock` is still allowed. Schema `format: uuid`; runtime any non-empty string. `Idempotent = false` to opt a command out. Queries never use the store or lock. `RequireConfirmation`: MCP `2026-07-28` elicits `confirmed`; `2025-11-25` returns `ConfirmationRequired` JSON. Lab: `orders.create` (key + `confirmed`), `demo.echo` (opt-out), `lab.ping` (query). Default Lab host stays on memory idempotency. Reload Cursor MCP after tool changes. diff --git a/docs/building-blocks/idempotency.md b/docs/building-blocks/idempotency.md index 5a2b3d7..6211078 100644 --- a/docs/building-blocks/idempotency.md +++ b/docs/building-blocks/idempotency.md @@ -71,4 +71,4 @@ Do not reintroduce Lab-local idempotency filter copies; use the package. ## Not this package -MCP write idempotency (`UseMemoryIdempotency`) lives in **BuildingBlocks.Mcp**. +MCP write idempotency (`UseMemoryIdempotency` / `UseDistributedIdempotency` + `UseRedisLock`) lives in **BuildingBlocks.Mcp**. It is wait-and-replay, not HTTP Processing/409, and does not reference this package. diff --git a/docs/building-blocks/mcp.md b/docs/building-blocks/mcp.md index 1d45f01..d4e599c 100644 --- a/docs/building-blocks/mcp.md +++ b/docs/building-blocks/mcp.md @@ -166,7 +166,7 @@ The invoker **never retries** a write. Idempotency is “same key → same store | | Command | Query | |--|---------|--------| | Default `Idempotent` | `true` | ignored — store is never used | -| Store registered (`UseMemoryIdempotency` or `IMcpIdempotencyStore`) | client **must** send `idempotencyKey` | no key in schema | +| Store registered (`UseMemoryIdempotency`, `UseDistributedIdempotency`, or `IMcpIdempotencyStore`) | client **must** send `idempotencyKey` | no key in schema | | `Idempotent = false` | no key (lab `demo.echo`) | — | Without a store, command tools do not require a key (nothing to replay against). Register a store in any host that exposes write tools to agents. @@ -181,15 +181,44 @@ Agents do not magically inject keys. If the field is required in `inputSchema`, ### Store behavior -`o.UseMemoryIdempotency(TimeSpan.FromHours(1))` registers a single in-process `MemoryIdempotencyStore` (optional TTL). Do not also `AddSingleton` unless you replace it. +`o.UseMemoryIdempotency(TimeSpan.FromHours(1))` registers a single in-process `MemoryIdempotencyStore` (optional TTL, process `SemaphoreSlim` wait-and-replay). Do not also `AddSingleton` unless you replace it. -Keys are namespaced as `toolName` + separator + client key so `orders.create` and another command cannot collide. Concurrent invokes with the same namespaced key share a `SemaphoreSlim`. Success is serialized to JSON and replayed as `JsonElement` (not `Deserialize`). +Keys are namespaced as `toolName` + separator + client key so `orders.create` and another command cannot collide. Concurrent invokes with the same namespaced key share a `SemaphoreSlim`. Success is serialized to JSON and replayed as `JsonElement` (not `Deserialize`). Queries never use the store or lock. -Multiple API instances: implement `IMcpIdempotencyStore` (Redis, etc.) and register it as singleton. Memory store is not shared across processes. +### Distributed (multi-instance) -### Confirmation +`UseMemoryIdempotency` is process-local. Farms call `o.UseDistributedIdempotency(configure).UseRedisLock()`. Resolving `IMcpInvoker` throws if the lock is missing — unlocked distributed execution is not used. `UseRedisLock` registers MCP `RedisMcpIdempotencyLock` on the host `IConnectionMultiplexer` (does not register Redis). Custom `IMcpIdempotencyLock` remains supported instead of `UseRedisLock`. -`RequireConfirmation = true` (lab `orders.create`) adds required `confirmed: true` in the schema. Agents must set it; the invoker rejects missing confirmation. +Wait-and-replay (not HTTP Processing/409): Get completed payload → TryAcquire(owner token, lease) → if not acquired, poll Get / retry acquire until `AcquireWaitBudget` → Get again → InvokeCore if still missing → Set on success → Release in `finally`. + +| Fact | Meaning | +|------|---------| +| Lock is required | `IDistributedCache` Get/Set alone does not serialize in-flight work. Resolving `IMcpInvoker` fails without a lock. | +| Lease (default 2 minutes) | In-flight safety window, **not** exactly-once. **No renewal in 1.1.0.** Keep it longer than the worst-case successful handler. | +| `AcquireWaitBudget` (default 30 seconds) | Waiters poll Get / retry acquire. Exhaustion returns MCP `Conflict`; the client should retry (replay if Set completed). This is **not** HTTP 409 Processing. | +| `PollDelay` (default 20 ms) | Delay between waiter poll attempts. | +| Lease expiry | Another instance may acquire and execute (overlap is allowed and characterized, not exactly-once). | +| Crash before Set | A later caller may execute again (at-least-once). | +| Set after successful InvokeCore fails | The computed success is still returned; persistence failure is logged; a later caller may execute again. | +| MCP vs HTTP keyspace | Payloads `mcp:idemp:{tool}\u001f{clientKey}`; lock `{payloadKey}:lock`. Not HTTP `Idempotency_*`. | +| Package boundary | BuildingBlocks.Mcp does not reference BuildingBlocks.Idempotency. Redis: `UseRedisLock` / `RedisMcpIdempotencyLock`. Custom SET NX: register `IMcpIdempotencyLock`. | + +```csharp +o.UseDistributedIdempotency(opts => +{ + opts.Lease = TimeSpan.FromMinutes(2); + opts.PayloadTtl = TimeSpan.FromHours(1); + opts.AcquireWaitBudget = TimeSpan.FromSeconds(30); + opts.PollDelay = TimeSpan.FromMilliseconds(20); +}) +.UseRedisLock(); +``` + +### Confirmation (2026 MRTR) + +`RequireConfirmation = true` (lab `orders.create`) adds required `confirmed: true` in the schema. The invoker still rejects missing confirmation with `McpErrorCode.ConfirmationRequired`. + +On MCP **2026-07-28** (`IsMrtrSupported`), that error is translated by the protocol adapter to SDK `InputRequiredException` / `resultType: input_required`: elicitation for `confirmed`, `requestState` `awaiting-confirmation` (opaque echo, not a server session). Accept sets `McpInvokeContext.Confirmed` and invokes once. Decline returns `ConfirmationRequired` JSON without invoking. MCP **2025-11-25** clients still receive `ConfirmationRequired` JSON. Sending `confirmed: true` skips elicitation on both revisions. Official C# clients auto-retry elicitation when `ElicitationHandler` is set. This is the `confirmed` write gate only — not a general elicitation or OAuth framework. ### Filters and limits diff --git a/docs/lab/README.md b/docs/lab/README.md index 8ca7280..a3fb280 100644 --- a/docs/lab/README.md +++ b/docs/lab/README.md @@ -58,7 +58,7 @@ Aspire: Postgres │ Redis │ RabbitMQ │ Memcached │ SigNoz |--------|-------------|---------| | Keyset pagination abuse | 1–2 | Careless HTTP/MCP cursor clients characterized | | HTTP Redis idempotency | 3, 4, 12, 15 | Miss/hit, concurrency, fingerprint, ProcessingTtl lease overlap → **BuildingBlocks.Idempotency 1.0.1** | -| MCP write / agent-client semantics | 6, 13, 14, 16 | Confirm+replay, regenerated keys amplify, concurrent same-key safe, **IMcpRateLimiter** bounds distinct-key storms | +| MCP write / agent-client semantics | 6, 13, 14, 16 | Confirm+replay, regenerated keys amplify, concurrent same-key safe, **IMcpRateLimiter** bounds distinct-key storms. Distributed wait-and-replay (`UseRedisLock`) + 2026 MRTR confirmation are **BuildingBlocks.Mcp 1.1.0** (Lab overlay `McpDistributedIdempotency` / `McpMrtrConfirmation`; default host stays memory). | | Outbox → bus → inbox happy path | 5, 8, 10 | HTTP + MCP parity; outbox row lifecycle | | Consumer dedup / failure | 7, 9, 11, 17 | Inbox dedup; retry/DLQ; `EnableDeduplication` + `processed_messages` | | Sync + async telemetry | TraceEvidence, 18 | In-process capture works; **W3C does not cross RabbitMQ** — correlate by `IntegrationEvent.Id` / `OrderId` | @@ -78,7 +78,7 @@ Do **not** extract: Scenario DSL, one-off test gates, fixed-permit rate limiters ## Package / versioning (current) -- Each packable project owns `` in its `.csproj` (e.g. `BuildingBlocks.Pagination.EntityFrameworkCore` `1.1.0`, `BuildingBlocks.Idempotency` `1.0.1`, `BuildingBlocks.Mcp` `1.0.0`). +- Each packable project owns `` in its `.csproj` (e.g. `BuildingBlocks.Pagination.EntityFrameworkCore` `1.1.0`, `BuildingBlocks.Idempotency` `1.0.1`, `BuildingBlocks.Mcp` `1.1.0`). - Release tags are package-prefixed (`mcp-v*`, `mediator-v*`, `telemetry-v*`, …) and must match ``. - Per-package GitHub Actions: `*-yml` CI + `*-release.yml` pack / Trusted Publishing to nuget.org (`idempotency.yml` / `idempotency-release.yml` for this package). - IntegrationTests / Exp 1–18 are **not** in CI (local Aspire/Docker). diff --git a/llms.txt b/llms.txt index 2efc0d8..ff21268 100644 --- a/llms.txt +++ b/llms.txt @@ -17,7 +17,7 @@ | Package | Version | NuGet | GitHub releases | |---------|---------|-------|-----------------| | BuildingBlocks.Mediator | 1.1.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Mediator/1.1.0) | [mediator-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=mediator-v) | -| BuildingBlocks.Mcp | 1.0.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Mcp/1.0.0) | [mcp-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=mcp-v) | +| BuildingBlocks.Mcp | 1.1.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Mcp/1.1.0) | [mcp-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=mcp-v) | | BuildingBlocks.Idempotency | 1.0.1 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Idempotency/1.0.1) | [idempotency-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=idempotency-v) | | BuildingBlocks.Pagination.EntityFrameworkCore | 1.1.0 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Pagination.EntityFrameworkCore/1.1.0) | [pagination-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=pagination-v) | | BuildingBlocks.Telemetry | 1.0.2 | [nuget.org](https://www.nuget.org/packages/BuildingBlocks.Telemetry/1.0.2) | [telemetry-v*](https://github.com/Maxofpower/FeatureFusion/releases?q=telemetry-v) | @@ -25,7 +25,7 @@ | BuildingBlocks.Domain | 1.0.0 (in-repo; not published yet) | `src/BuildingBlocks/Domain` | — | | BuildingBlocks.Domain.EntityFrameworkCore | 1.0.0 (in-repo; not published yet) | `src/BuildingBlocks/Domain.EntityFrameworkCore` | — | -**Highlights:** Pagination **1.1.0** — any-width Npgsql row comparison, `NULLS FIRST/LAST` interceptor, `HasKeysetIndex` + `NullOrder`. Idempotency **1.0.1** — package icon, System.Text.Json only (no Newtonsoft). +**Highlights:** MCP **1.1.0** — distributed wait-and-replay idempotency (`UseDistributedIdempotency` + `UseRedisLock`; not HTTP 409; lease is not exactly-once) and 2026 MRTR confirmation elicitation. Pagination **1.1.0** — any-width Npgsql row comparison, `NULLS FIRST/LAST` interceptor, `HasKeysetIndex` + `NullOrder`. Idempotency **1.0.1** — package icon, System.Text.Json only (no Newtonsoft). ## BuildingBlocks — NuGet READMEs @@ -58,7 +58,7 @@ - [Idempotency](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/idempotency.md) (1.0.1) - [Pagination](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/pagination.md) (1.1.0) - [Pagination test matrix](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/PAGINATION_TEST_MATRIX.md) -- [MCP](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/mcp.md) +- [MCP](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/mcp.md) (1.1.0) - [Telemetry](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/building-blocks/telemetry.md) - [ADR 0003 — pagination keyset](https://github.com/Maxofpower/FeatureFusion/blob/main/docs/adr/0003-pagination-keyset.md) diff --git a/src/BuildingBlocks/Mcp/AGENTS.md b/src/BuildingBlocks/Mcp/AGENTS.md index 70030fd..554d92a 100644 --- a/src/BuildingBlocks/Mcp/AGENTS.md +++ b/src/BuildingBlocks/Mcp/AGENTS.md @@ -55,7 +55,13 @@ api.MapPost("/items", CreateItem).WithMcp(app, "items.create", "Create an item") ## Idempotency -Writes (Command / POST / PUT) are never retried. Commands default to requiring `idempotencyKey` when `UseMemoryIdempotency` (or another `IMcpIdempotencyStore`) is registered. Schema `format: uuid` is a hint; any non-empty string is accepted. Queries never use the store. `Idempotent = false` opts a command out (lab `demo.echo`). Keys are namespaced per tool; in-flight calls lock; success replays as `JsonElement`. Multi-instance: Redis via `IMcpIdempotencyStore`. `RequireConfirmation` adds required `confirmed: true`. +Writes (Command / POST / PUT) are never retried. Commands default to requiring `idempotencyKey` when `UseMemoryIdempotency` / `UseDistributedIdempotency` (or another `IMcpIdempotencyStore`) is registered. Schema `format: uuid` is a hint; any non-empty string is accepted. Queries never use the store or lock. `Idempotent = false` opts a command out (lab `demo.echo`). Keys are namespaced per tool (`mcp:idemp:{tool}\u001f{clientKey}` on the distributed cache); in-flight calls wait and replay (not HTTP 409). Success replays as `JsonElement`. + +**Single instance:** `o.UseMemoryIdempotency(ttl)` (process `SemaphoreSlim` wait-and-replay). + +**Multi-instance:** `o.UseDistributedIdempotency().UseRedisLock()` (host `IDistributedCache` + `IConnectionMultiplexer`). Cache Get/Set alone does **not** serialize in-flight work. Resolving `IMcpInvoker` fails without a lock (never execute unlocked). Custom `IMcpIdempotencyLock` is still allowed instead of `UseRedisLock`. This package does **not** reference `BuildingBlocks.Idempotency`. Default lease is **2 minutes** (safety window, not exactly-once; **no renewal** in 1.1.0). Lease expiry, crash before Set, or Set failure after a successful invoke can overlap or retry (at-least-once). Default `AcquireWaitBudget` is **30 seconds**; exhaustion is MCP `Conflict` (client retries — not HTTP 409 Processing). MCP keys are distinct from HTTP `Idempotency_*`. Queries never use the store or lock. + +**Confirmation (1.1.0):** `RequireConfirmation` still requires `confirmed: true`. MCP `2026-07-28` clients get `InputRequiredException` elicitation (`requestState` `awaiting-confirmation`); accept invokes, decline does not. `2025-11-25` stays `ConfirmationRequired` JSON. `confirmed: true` skips elicitation. Agents: send a **new UUID** for a new write; **reuse** the key only on retry of that write. Do not invent keys for query tools. diff --git a/src/BuildingBlocks/Mcp/Abstractions/McpContracts.cs b/src/BuildingBlocks/Mcp/Abstractions/McpContracts.cs index d96d3e7..9e8e8bb 100644 --- a/src/BuildingBlocks/Mcp/Abstractions/McpContracts.cs +++ b/src/BuildingBlocks/Mcp/Abstractions/McpContracts.cs @@ -42,7 +42,8 @@ public readonly record struct McpRateLimitDecision(bool Allowed, int? RetryAfter /// /// Optional idempotency store. When registered, duplicate keys return the cached payload without invoking again. /// The invoker namespaces keys as toolName + key. is single-instance with optional TTL. -/// Multi-instance hosts should register a distributed implementation (for example Redis) of this interface. +/// Multi-instance hosts must use UseDistributedIdempotency plus UseRedisLock (or a custom +/// ) — a Get/Set store alone does not serialize in-flight calls across processes. /// public interface IMcpIdempotencyStore { @@ -53,6 +54,26 @@ public interface IMcpIdempotencyStore Task SetAsync(string key, string payloadJson, CancellationToken cancellationToken); } +/// +/// Distributed in-flight lock for MCP write idempotency (wait-and-replay). +/// SET NX + server-side lease; compare-and-delete release. Built-in Redis: . +/// Not HTTP IIdempotencyLock. +/// +public interface IMcpIdempotencyLock +{ + /// + /// Attempts to acquire for until elapses. + /// Returns when another owner holds it. Does not wait. + /// + Task TryAcquireAsync(string key, string ownerToken, TimeSpan lease, CancellationToken cancellationToken); + + /// + /// Releases only when matches the holder. + /// Returns when this caller does not own the lock. + /// + Task ReleaseAsync(string key, string ownerToken, CancellationToken cancellationToken); +} + /// /// Maps a handler return value (including host Result<T>) to boxed as object. /// diff --git a/src/BuildingBlocks/Mcp/Abstractions/McpIdempotencyOptions.cs b/src/BuildingBlocks/Mcp/Abstractions/McpIdempotencyOptions.cs new file mode 100644 index 0000000..4f93806 --- /dev/null +++ b/src/BuildingBlocks/Mcp/Abstractions/McpIdempotencyOptions.cs @@ -0,0 +1,28 @@ +namespace BuildingBlocks.Mcp; + +/// +/// Options for distributed MCP write idempotency (wait-and-replay). +/// The lease is the in-flight safety window, not an exactly-once guarantee. There is no lease renewal. +/// +public sealed class McpIdempotencyOptions +{ + /// + /// How long a lock holder may run InvokeCore before another instance may acquire. + /// Default 2 minutes. Keep this longer than the worst-case successful handler. + /// + public TimeSpan Lease { get; set; } = TimeSpan.FromMinutes(2); + + /// + /// TTL for completed success payloads. means the cache implementation's default (no absolute expiry from this package). + /// + public TimeSpan? PayloadTtl { get; set; } = TimeSpan.FromHours(1); + + /// + /// How long a waiter polls Get / retries acquire before . + /// Waiters replay on a completed payload; they do not return HTTP Processing/409 while the lease is valid. + /// + public TimeSpan AcquireWaitBudget { get; set; } = TimeSpan.FromSeconds(30); + + /// Delay between waiter poll attempts. + public TimeSpan PollDelay { get; set; } = TimeSpan.FromMilliseconds(20); +} diff --git a/src/BuildingBlocks/Mcp/BuildingBlocks.Mcp.csproj b/src/BuildingBlocks/Mcp/BuildingBlocks.Mcp.csproj index 94e7534..1493feb 100644 --- a/src/BuildingBlocks/Mcp/BuildingBlocks.Mcp.csproj +++ b/src/BuildingBlocks/Mcp/BuildingBlocks.Mcp.csproj @@ -12,19 +12,19 @@ true BuildingBlocks.Mcp BuildingBlocks.Mcp - 1.0.0 + 1.1.0 Mohammad Hasan Hosseini Mohammad Hasan Hosseini Copyright (c) 2026 Mohammad Hasan Hosseini - Map application message types and public static endpoint methods to MCP tools on the official C# SDK, or MapTool handlers. Deny-by-default catalog, typed McpResult, HTTP + opt-in stdio. Does not require BuildingBlocks.Mediator. - mcp;model-context-protocol;cqrs;tools;cursor;claude;aspnetcore;net8;net9;net10 + Map application message types and public static endpoint methods to MCP tools on the official C# SDK, or MapTool handlers. Deny-by-default catalog, typed McpResult, HTTP + opt-in stdio. Write idempotency (memory or distributed wait-and-replay with UseRedisLock). 2026 MRTR confirmation elicitation. Does not require BuildingBlocks.Mediator or BuildingBlocks.Idempotency. + mcp;model-context-protocol;cqrs;tools;cursor;claude;aspnetcore;redis;idempotency;net8;net9;net10 MIT https://github.com/Maxofpower/FeatureFusion https://github.com/Maxofpower/FeatureFusion git PACKAGE_README.md mcp-icon.png - 1.0.0: [McpTool] on types or static endpoint methods, or MapTool; inputSchema; structuredContent; namespaced idempotency (schema format uuid); analyzers BBMCP001–005. https://github.com/Maxofpower/FeatureFusion/blob/main/CHANGELOG.md + 1.1.0: UseDistributedIdempotency + UseRedisLock (RedisMcpIdempotencyLock over host IConnectionMultiplexer; wait-and-replay, not HTTP 409; lease is not exactly-once); custom IMcpIdempotencyLock still supported; 2026 MRTR confirmation via InputRequiredException elicitation (2025-11-25 stays ConfirmationRequired JSON). https://github.com/Maxofpower/FeatureFusion/blob/main/CHANGELOG.md true true true @@ -47,6 +47,7 @@ + diff --git a/src/BuildingBlocks/Mcp/DependencyInjection/McpServiceCollectionExtensions.cs b/src/BuildingBlocks/Mcp/DependencyInjection/McpServiceCollectionExtensions.cs index 0e62c2c..1615ede 100644 --- a/src/BuildingBlocks/Mcp/DependencyInjection/McpServiceCollectionExtensions.cs +++ b/src/BuildingBlocks/Mcp/DependencyInjection/McpServiceCollectionExtensions.cs @@ -5,6 +5,7 @@ using Microsoft.AspNetCore.Routing; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using StackExchange.Redis; namespace BuildingBlocks.Mcp; @@ -47,7 +48,10 @@ public McpBuilder UseTelemetry(Action? configure = null) /// /// Registers in-process (single instance). Optional TTL. - /// Commands require idempotencyKey when this store is registered. Multi-instance hosts should register Redis (or similar) as instead. + /// Commands require idempotencyKey when this store is registered. + /// Concurrent calls on this process wait and replay (not HTTP 409). Multi-instance hosts must + /// call plus (or a custom + /// ) — Get/Set cache alone does not serialize in-flight work. /// public McpBuilder UseMemoryIdempotency(TimeSpan? timeToLive = null) { @@ -55,6 +59,39 @@ public McpBuilder UseMemoryIdempotency(TimeSpan? timeToLive = null) return this; } + /// + /// Registers completed-payload storage on the host . + /// Requires a registered ; resolving fails without one + /// (unlocked distributed execution is never used). Chain for the built-in Redis lock, + /// or register a custom . Keys are prefixed mcp:idemp:. + /// The lease (default 2 minutes) is the in-flight window, not exactly-once execution; there is no lease renewal. + /// + public McpBuilder UseDistributedIdempotency(Action? configure = null) + { + var options = new McpIdempotencyOptions(); + configure?.Invoke(options); + Services.RemoveAll(); + Services.AddSingleton(options); + Services.AddSingleton(); + Services.AddSingleton(sp => + new DistributedCacheIdempotencyStore( + sp.GetRequiredService(), + options.PayloadTtl)); + return this; + } + + /// + /// Registers as using the host + /// . Does not register Redis or IDistributedCache — the host + /// already owns those. Pair with . For a non-Redis lock, register + /// instead of calling this method. + /// + public McpBuilder UseRedisLock() + { + Services.AddRedisMcpIdempotencyLock(); + return this; + } + /// /// Registers stdio transport (Claude Desktop / Cursor command). Do not enable on a web API host — stdin is not a JSON-RPC pipe there. /// @@ -198,6 +235,19 @@ public DelegateMessageDispatcher(IServiceProvider sp, Func public static class McpServiceCollectionExtensions { + /// + /// Registers as using the host + /// . Prefer when chaining + /// . + /// + public static IServiceCollection AddRedisMcpIdempotencyLock(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + services.TryAddSingleton(sp => + new RedisMcpIdempotencyLock(sp.GetRequiredService())); + return services; + } + /// /// Adds BuildingBlocks.Mcp services and official MCP server handlers. /// @@ -234,6 +284,13 @@ public static McpBuilder AddBuildingBlocksMcp(this IServiceCollection services, services.AddSingleton(sp => { + if (sp.GetService() is not null + && sp.GetService() is null) + { + throw new InvalidOperationException( + "UseDistributedIdempotency requires an IMcpIdempotencyLock. Call UseRedisLock() with the host IConnectionMultiplexer, or register a custom IMcpIdempotencyLock."); + } + var catalog = sp.GetRequiredService>(); var telemetry = sp.GetService(); return new McpInvoker( @@ -301,6 +358,8 @@ public static IServiceCollection AddMcpFeatureFlagEvaluator( } } +internal sealed class McpDistributedIdempotencyRequired; + /// Holds an optional feature-flag evaluator. public sealed class FeatureFlagCallbackOptions { diff --git a/src/BuildingBlocks/Mcp/Invocation/DefaultImplementations.cs b/src/BuildingBlocks/Mcp/Invocation/DefaultImplementations.cs index 7af53a0..3f305d6 100644 --- a/src/BuildingBlocks/Mcp/Invocation/DefaultImplementations.cs +++ b/src/BuildingBlocks/Mcp/Invocation/DefaultImplementations.cs @@ -6,7 +6,7 @@ namespace BuildingBlocks.Mcp; /// /// In-memory for single-instance hosts and tests. /// Keys should already be namespaced by the invoker (toolName + key). Optional TTL; expired entries are ignored. -/// Not a distributed store — register Redis (or similar) in production farms. +/// Not a distributed store — multi-instance hosts use UseDistributedIdempotency plus UseRedisLock (or a custom ). /// public sealed class MemoryIdempotencyStore : IMcpIdempotencyStore { diff --git a/src/BuildingBlocks/Mcp/Invocation/DistributedCacheIdempotencyStore.cs b/src/BuildingBlocks/Mcp/Invocation/DistributedCacheIdempotencyStore.cs new file mode 100644 index 0000000..60880cd --- /dev/null +++ b/src/BuildingBlocks/Mcp/Invocation/DistributedCacheIdempotencyStore.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Caching.Distributed; + +namespace BuildingBlocks.Mcp; + +/// +/// over host . +/// Prefixes keys with so MCP payloads +/// do not collide with HTTP Idempotency_* entries. This is completed-payload storage only — +/// in-flight serialization requires . +/// +public sealed class DistributedCacheIdempotencyStore : IMcpIdempotencyStore +{ + private readonly IDistributedCache _cache; + private readonly TimeSpan? _payloadTtl; + + /// Creates a store. of zero or less is treated as no absolute expiry. + public DistributedCacheIdempotencyStore(IDistributedCache cache, TimeSpan? payloadTtl = null) + { + _cache = cache ?? throw new ArgumentNullException(nameof(cache)); + _payloadTtl = payloadTtl is { } t && t > TimeSpan.Zero ? t : null; + } + + /// + public async Task GetAsync(string key, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + return await _cache.GetStringAsync(ToCacheKey(key), cancellationToken).ConfigureAwait(false); + } + + /// + public async Task SetAsync(string key, string payloadJson, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentNullException.ThrowIfNull(payloadJson); + + DistributedCacheEntryOptions? options = null; + if (_payloadTtl is { } ttl) + options = new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = ttl }; + + await _cache.SetStringAsync(ToCacheKey(key), payloadJson, options ?? new DistributedCacheEntryOptions(), cancellationToken) + .ConfigureAwait(false); + } + + private static string ToCacheKey(string invokerKey) + => invokerKey.StartsWith(McpDefaults.IdempotencyPayloadKeyPrefix, StringComparison.Ordinal) + ? invokerKey + : McpDefaults.IdempotencyPayloadKeyPrefix + invokerKey; +} diff --git a/src/BuildingBlocks/Mcp/Invocation/McpInvoker.cs b/src/BuildingBlocks/Mcp/Invocation/McpInvoker.cs index b4b5640..4e66210 100644 --- a/src/BuildingBlocks/Mcp/Invocation/McpInvoker.cs +++ b/src/BuildingBlocks/Mcp/Invocation/McpInvoker.cs @@ -1,6 +1,7 @@ using System.Collections.Concurrent; using System.Diagnostics; using System.Text.Json; +using Microsoft.Extensions.Logging.Abstractions; namespace BuildingBlocks.Mcp.Invocation; @@ -105,6 +106,20 @@ public async Task> ListVisibleAsync( if (tool.Kind == McpToolKind.Command && tool.Idempotent && ctx.IdempotencyKey is not null && _idempotency is not null) { var cacheKey = CacheKey(tool.Name, ctx.IdempotencyKey); + var distributedLock = _services.GetService(); + if (distributedLock is not null) + { + try + { + return await InvokeDistributedIdempotentAsync( + tool, args, ctx, cacheKey, distributedLock, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return McpResult.Fail(McpErrorCode.Canceled, "The MCP tool call was canceled."); + } + } + var gate = _idempotencyGates.GetOrAdd(cacheKey, static _ => new SemaphoreSlim(1, 1)); await gate.WaitAsync(cancellationToken).ConfigureAwait(false); try @@ -131,6 +146,152 @@ public async Task> ListVisibleAsync( return await InvokeCoreAsync(tool, args, ctx, cancellationToken).ConfigureAwait(false); } + /// + /// Wait-and-replay across instances: Get → TryAcquire → Get → InvokeCore → Set → Release. + /// Waiters poll; they do not receive HTTP Processing/409 while the lease is valid. + /// + private async Task> InvokeDistributedIdempotentAsync( + McpToolDescriptor tool, + JsonElement args, + McpInvokeContext ctx, + string cacheKey, + IMcpIdempotencyLock distributedLock, + CancellationToken cancellationToken) + { + var options = _services.GetService() ?? new McpIdempotencyOptions(); + var logger = _services.GetService()?.CreateLogger(typeof(McpInvoker)) + ?? NullLogger.Instance; + var clock = _services.GetService() ?? TimeProvider.System; + var lockKey = McpDefaults.FormatIdempotencyLockKey(tool.Name, ctx.IdempotencyKey!); + var waitUntil = clock.GetUtcNow() + options.AcquireWaitBudget; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + string? cached; + try + { + cached = await _idempotency!.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "MCP idempotency store Get failed for tool {Tool}.", tool.Name); + return StoreInfrastructureFailure(ex); + } + + if (cached is not null) + return McpResult.Ok(ParseCachedPayload(cached)); + + var ownerToken = Guid.NewGuid().ToString("N"); + bool acquired; + try + { + acquired = await distributedLock + .TryAcquireAsync(lockKey, ownerToken, options.Lease, cancellationToken) + .ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "MCP idempotency lock acquire failed for tool {Tool}.", tool.Name); + return StoreInfrastructureFailure(ex); + } + + if (acquired) + { + try + { + try + { + cached = await _idempotency.GetAsync(cacheKey, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "MCP idempotency store Get failed after acquire for tool {Tool}.", tool.Name); + return StoreInfrastructureFailure(ex); + } + + if (cached is not null) + return McpResult.Ok(ParseCachedPayload(cached)); + + McpResult computed; + try + { + computed = await InvokeCoreAsync(tool, args, ctx, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + + if (computed.IsSuccess) + { + try + { + var json = JsonSerializer.Serialize(computed.Value, McpJson.Options); + await _idempotency.SetAsync(cacheKey, json, cancellationToken).ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError( + ex, + "MCP idempotency store Set failed after a successful invoke for tool {Tool}. Returning the computed result; another instance may execute after lease expiry.", + tool.Name); + } + } + + return computed; + } + finally + { + try + { + await distributedLock.ReleaseAsync(lockKey, ownerToken, CancellationToken.None) + .ConfigureAwait(false); + } + catch (Exception ex) + { + logger.LogError(ex, "MCP idempotency lock release failed for tool {Tool}.", tool.Name); + } + } + } + + if (clock.GetUtcNow() >= waitUntil) + { + return McpResult.Fail( + McpErrorCode.Conflict, + $"Tool '{tool.Name}' is still in flight for this idempotency key; wait budget elapsed."); + } + + try + { + await Task.Delay(options.PollDelay, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + } + } + + private McpResult StoreInfrastructureFailure(Exception ex) + { + var message = _includeExceptionDetails ? ex.Message : "The tool failed."; + return McpResult.Fail(McpErrorCode.Internal, message); + } + private async Task> InvokeCoreAsync( McpToolDescriptor tool, JsonElement args, diff --git a/src/BuildingBlocks/Mcp/McpDefaults.cs b/src/BuildingBlocks/Mcp/McpDefaults.cs index c759e4f..4b4411c 100644 --- a/src/BuildingBlocks/Mcp/McpDefaults.cs +++ b/src/BuildingBlocks/Mcp/McpDefaults.cs @@ -45,4 +45,22 @@ public static class McpDefaults /// MCP resource URI for the enabled tool catalog. /// public const string CatalogResourceUri = "catalog://tools"; + + /// + /// Prefix for distributed completed-payload keys. Distinct from HTTP Idempotency_*. + /// + public const string IdempotencyPayloadKeyPrefix = "mcp:idemp:"; + + /// Suffix appended to the prefixed payload key for the in-flight lock. + public const string IdempotencyLockKeySuffix = ":lock"; + + /// + /// Lock key for : mcp:idemp:{tool}\u001f{clientKey}:lock. + /// + public static string FormatIdempotencyLockKey(string toolName, string clientIdempotencyKey) + { + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + ArgumentException.ThrowIfNullOrWhiteSpace(clientIdempotencyKey); + return IdempotencyPayloadKeyPrefix + toolName + "\u001f" + clientIdempotencyKey + IdempotencyLockKeySuffix; + } } diff --git a/src/BuildingBlocks/Mcp/PACKAGE_README.md b/src/BuildingBlocks/Mcp/PACKAGE_README.md index 40f5c1a..2ce674a 100644 --- a/src/BuildingBlocks/Mcp/PACKAGE_README.md +++ b/src/BuildingBlocks/Mcp/PACKAGE_README.md @@ -9,11 +9,16 @@ Map **application message types** (commands, queries, DTOs) and **public static **When to use:** Cursor or Claude should call the **same logic** as HTTP. Opt-in with `[McpTool]` on a message type **or** a public static Minimal API method (`WithMcp` optional), or use `MapTool`. Unmarked types/methods are never tools. **MVC controllers are unsupported for now.** HTTP-only inputs (`FromHeader`) cannot be the MCP body. +## What's new in 1.1.0 + +- **Distributed write idempotency:** `UseDistributedIdempotency` + `UseRedisLock` (wait-and-replay across instances; not HTTP Processing/409). Completed payloads on host `IDistributedCache`; in-flight SET NX PX lease via MCP `RedisMcpIdempotencyLock` on the host `IConnectionMultiplexer` (default 2 minutes, **no renewal** — not exactly-once). Fail-closed if the lock is missing. Custom `IMcpIdempotencyLock` still works. Wait-budget exhaustion (`AcquireWaitBudget`, default 30 seconds) is MCP `Conflict` (client retries). MCP keys `mcp:idemp:…` are distinct from HTTP `Idempotency_*`. This package does not reference `BuildingBlocks.Idempotency`. +- **2026 MRTR confirmation:** unconfirmed `RequireConfirmation` writes elicit `confirmed` via SDK `InputRequiredException` when the client is MCP `2026-07-28` (`requestState` `awaiting-confirmation`, opaque echo). Accept invokes; decline returns `ConfirmationRequired` without invoking. `2025-11-25` still returns `ConfirmationRequired` JSON. `confirmed: true` skips elicitation. + ## What's in 1.0.0 - Message types or public static endpoint methods as MCP tools (`[McpTool]`, deny-by-default scanner), **or** `MapTool` handlers - Scoped `MapTool` overload: handler receives `IServiceProvider` from a new DI scope -- Idempotency: `UseMemoryIdempotency(ttl)` on the builder; commands (POST/PUT) require `idempotencyKey` (`format: uuid` in the schema; any non-empty string accepted); queries never use the store. Namespaced keys, lock, `JsonElement` replay. Redis via `IMcpIdempotencyStore`. +- Idempotency: `UseMemoryIdempotency(ttl)` on the builder; commands (POST/PUT) require `idempotencyKey` (`format: uuid` in the schema; any non-empty string accepted); queries never use the store. Namespaced keys, in-process wait-and-replay, `JsonElement` replay. - `inputSchema` from CLR: defaults/nullable = optional, enum members, `[Description]` / Swagger parameter text - Successful calls return JSON text **and** `structuredContent` - Safe writes: `idempotencyKey`, confirmation, timeout, `IMcpToolFilter`, `IMcpRateLimiter`, `catalog://tools` @@ -148,9 +153,28 @@ Register the in-memory store (single process) on the builder — do not add `IMc o.UseMemoryIdempotency(TimeSpan.FromHours(1)); ``` -Multi-instance hosts: implement `IMcpIdempotencyStore` (Redis, etc.) and register it as a singleton. Keys are **namespaced per tool**. Concurrent calls with the same key share a lock. Successful results are stored as JSON and replayed as `JsonElement`. +Multi-instance hosts call `UseDistributedIdempotency` **and** `UseRedisLock` (host already has `IDistributedCache` + `IConnectionMultiplexer`). A shared Get/Set cache without a lock does not serialize in-flight calls. Waiters poll and replay; they do not get HTTP Processing/409 while the lease is valid. Custom locks: register `IMcpIdempotencyLock` instead of `UseRedisLock`. + +```csharp +o.UseDistributedIdempotency(opts => +{ + opts.Lease = TimeSpan.FromMinutes(2); // in-flight window; no renewal + opts.PayloadTtl = TimeSpan.FromHours(1); + opts.AcquireWaitBudget = TimeSpan.FromSeconds(30); // then MCP Conflict — client retries + opts.PollDelay = TimeSpan.FromMilliseconds(20); +}) +.UseRedisLock(); // RedisMcpIdempotencyLock over host IConnectionMultiplexer +``` + +The lease (default 2 minutes) is the in-flight safety window, **not** exactly-once execution — there is no lease renewal in 1.1.0. Lease expiry, a crash before Set, or a Set failure after a successful invoke can cause a second execution (at-least-once). If waiters exhaust `AcquireWaitBudget` (default 30 seconds) they receive MCP `Conflict` and should retry (replay if Set completed). Payload keys use `mcp:idemp:{tool}\u001f{clientKey}` (lock key `{payloadKey}:lock`), distinct from HTTP `Idempotency_*`. This package does not reference `BuildingBlocks.Idempotency`. Queries never use the store or lock. Successful results are stored as JSON and replayed as `JsonElement`. + +## Confirmation (MRTR) + +`RequireConfirmation = true` adds required `confirmed: true` in the schema. The invoker still rejects missing confirmation with `McpErrorCode.ConfirmationRequired`. + +On MCP **2026-07-28** (`IsMrtrSupported`), that error is translated to SDK `InputRequiredException`: elicitation for `confirmed`, `requestState` `awaiting-confirmation` (opaque echo, not a session). Accept sets `Confirmed` and invokes once; decline returns `ConfirmationRequired` without invoking. On MCP **2025-11-25**, the tool error stays JSON. Sending `confirmed: true` skips elicitation on both revisions. Official C# clients auto-retry elicitation when `ElicitationHandler` is set. -Cursor/Claude fill `idempotencyKey` because the schema marks it **required**. They do not invent a key unless the field exists. Generate a **new UUID** for a new write; **reuse** the same key only when retrying that same write (timeouts, disconnects). `RequireConfirmation = true` adds required `confirmed: true` (lab `orders.create`). Lab `demo.echo` uses `Idempotent = false` so smoke calls need no key. +Cursor/Claude fill `idempotencyKey` because the schema marks it **required**. They do not invent a key unless the field exists. Generate a **new UUID** for a new write; **reuse** the same key only when retrying that same write (timeouts, disconnects). Lab `orders.create` uses confirmation + a key. Lab `demo.echo` uses `Idempotent = false` so smoke calls need no key. ## Transport and Cursor @@ -184,7 +208,7 @@ Cursor HTTP (API must already be running): - **MVC controllers are unsupported for now** (actions, `[FromHeader]`, `ActionResult`). Use public static Minimal API methods, message types, or `MapTool`. - Not OpenAPI → MCP, not a SOLID linter. `[FromHeader]` DTOs stay HTTP-only. -- No prompts, elicitation, or OAuth +- No prompts, OAuth, or MCP Apps. Confirmation elicitation is only the 2026 MRTR `confirmed` flow above — not a general elicitation framework. - Do not call `UseStdioTransport()` on a web API - Production hosts should leave MCP unmapped (FeatureFusion registers it only in Development) diff --git a/src/BuildingBlocks/Mcp/Protocol/McpProtocolRegistration.cs b/src/BuildingBlocks/Mcp/Protocol/McpProtocolRegistration.cs index 27bf4d8..031bd70 100644 --- a/src/BuildingBlocks/Mcp/Protocol/McpProtocolRegistration.cs +++ b/src/BuildingBlocks/Mcp/Protocol/McpProtocolRegistration.cs @@ -34,25 +34,29 @@ private static async ValueTask ListToolsAsync(RequestContext
  • CallToolAsync(RequestContext request, CancellationToken cancellationToken) { var invoker = GetInvoker(request.Services); - var ctx = CreateContext(request.Services); var name = request.Params?.Name ?? string.Empty; JsonElement args = default; if (request.Params?.Arguments is { Count: > 0 } dict) args = JsonSerializer.SerializeToElement(dict, McpJson.Options); + if (IsConfirmationDeclined(request.Params)) + return ToErrorCallResult(ConfirmationRequired(name)); + + var ctx = CreateContext(request.Services, confirmed: IsConfirmationAccepted(request.Params)); var result = await invoker.InvokeAsync(name, args, ctx, cancellationToken).ConfigureAwait(false); if (result.IsSuccess) return ToSuccessCallResult(result.Value); - var errorJson = JsonSerializer.Serialize(result.Error, McpJson.Options); - return new CallToolResult - { - IsError = true, - Content = [new TextContentBlock { Text = errorJson }] - }; + if (result.Error?.Code == McpErrorCode.ConfirmationRequired && request.Server.IsMrtrSupported) + throw CreateConfirmationInputRequired(name); + + return ToErrorCallResult(result); } private static ValueTask ListResourcesAsync(RequestContext request, CancellationToken cancellationToken) @@ -124,15 +128,91 @@ private static IMcpInvoker GetInvoker(IServiceProvider? services) => (services ?? throw new InvalidOperationException("MCP request has no IServiceProvider.")) .GetRequiredService(); - private static McpInvokeContext CreateContext(IServiceProvider? services) + private static McpInvokeContext CreateContext(IServiceProvider? services, bool confirmed = false) { if (services is null) return McpInvokeContext.None; var accessor = services.GetService(); var user = accessor?.HttpContext?.User; - return new McpInvokeContext(user, null, DryRun: false, Confirmed: false); + return new McpInvokeContext(user, null, DryRun: false, Confirmed: confirmed); } + private static CallToolResult ToErrorCallResult(McpResult result) + { + var errorJson = JsonSerializer.Serialize(result.Error, McpJson.Options); + return new CallToolResult + { + IsError = true, + Content = [new TextContentBlock { Text = errorJson }] + }; + } + + private static McpResult ConfirmationRequired(string toolName) + => McpResult.Fail( + McpErrorCode.ConfirmationRequired, + $"Tool '{toolName}' requires '{McpDefaults.ConfirmedArgument}' to be true."); + + private static InputRequiredException CreateConfirmationInputRequired(string toolName) + { + return new InputRequiredException( + new Dictionary + { + [ConfirmationInputRequestKey] = InputRequest.ForElicitation(new ElicitRequestParams + { + Message = $"Confirm execution of '{toolName}'. Accept to proceed, or decline to cancel.", + RequestedSchema = new ElicitRequestParams.RequestSchema + { + Properties = new Dictionary + { + [McpDefaults.ConfirmedArgument] = new ElicitRequestParams.BooleanSchema + { + Description = "Must be true to execute this write." + } + }, + Required = [McpDefaults.ConfirmedArgument] + } + }) + }, + requestState: ConfirmationRequestState); + } + + private static bool IsConfirmationAccepted(CallToolRequestParams? parameters) + { + if (!TryReadConfirmationElicit(parameters, out var elicit) || elicit is null) + return false; + if (!elicit.IsAccepted) + return false; + if (elicit.Content is { } content + && content.TryGetValue(McpDefaults.ConfirmedArgument, out var confirmed) + && IsJsonFalse(confirmed)) + return false; + return true; + } + + private static bool IsConfirmationDeclined(CallToolRequestParams? parameters) + { + if (!TryReadConfirmationElicit(parameters, out var elicit) || elicit is null) + return false; + return !IsConfirmationAccepted(parameters); + } + + private static bool TryReadConfirmationElicit(CallToolRequestParams? parameters, out ElicitResult? elicit) + { + elicit = null; + if (parameters?.InputResponses is not { Count: > 0 } responses) + return false; + if (!responses.TryGetValue(ConfirmationInputRequestKey, out var response)) + return false; + elicit = response.Deserialize(InputResponse.ElicitResultJsonTypeInfo); + return elicit is not null; + } + + private static bool IsJsonFalse(JsonElement value) + => value.ValueKind == JsonValueKind.False + || (value.ValueKind == JsonValueKind.String + && bool.TryParse(value.GetString(), out var parsed) + && !parsed); + internal static Tool ToTool(McpToolDescriptor d) { var properties = new Dictionary(); diff --git a/src/BuildingBlocks/Mcp/PublicAPI.Shipped.txt b/src/BuildingBlocks/Mcp/PublicAPI.Shipped.txt index 7c05504..bf56b1c 100644 --- a/src/BuildingBlocks/Mcp/PublicAPI.Shipped.txt +++ b/src/BuildingBlocks/Mcp/PublicAPI.Shipped.txt @@ -22,6 +22,11 @@ namespace BuildingBlocks.Mcp System.Threading.Tasks.Task GetAsync(string key, System.Threading.CancellationToken cancellationToken); System.Threading.Tasks.Task SetAsync(string key, string payloadJson, System.Threading.CancellationToken cancellationToken); } + public interface IMcpIdempotencyLock + { + System.Threading.Tasks.Task TryAcquireAsync(string key, string ownerToken, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken); + System.Threading.Tasks.Task ReleaseAsync(string key, string ownerToken, System.Threading.CancellationToken cancellationToken); + } public interface IMcpResultMapper { BuildingBlocks.Mcp.McpResult Map(object? handlerResult); @@ -113,6 +118,9 @@ namespace BuildingBlocks.Mcp public const string DryRunArgument = "dryRun"; public const string ConfirmedArgument = "confirmed"; public const string CatalogResourceUri = "catalog://tools"; + public const string IdempotencyPayloadKeyPrefix = "mcp:idemp:"; + public const string IdempotencyLockKeySuffix = ":lock"; + public static string FormatIdempotencyLockKey(string toolName, string clientIdempotencyKey) { throw null!; } } public enum McpErrorCode { @@ -189,6 +197,26 @@ namespace BuildingBlocks.Mcp public System.Threading.Tasks.Task GetAsync(string key, System.Threading.CancellationToken cancellationToken) { throw null!; } public System.Threading.Tasks.Task SetAsync(string key, string payloadJson, System.Threading.CancellationToken cancellationToken) { throw null!; } } + public sealed class DistributedCacheIdempotencyStore : BuildingBlocks.Mcp.IMcpIdempotencyStore + { + public DistributedCacheIdempotencyStore(Microsoft.Extensions.Caching.Distributed.IDistributedCache cache, System.TimeSpan? payloadTtl = null) { } + public System.Threading.Tasks.Task GetAsync(string key, System.Threading.CancellationToken cancellationToken) { throw null!; } + public System.Threading.Tasks.Task SetAsync(string key, string payloadJson, System.Threading.CancellationToken cancellationToken) { throw null!; } + } + public sealed class RedisMcpIdempotencyLock : BuildingBlocks.Mcp.IMcpIdempotencyLock + { + public RedisMcpIdempotencyLock(StackExchange.Redis.IConnectionMultiplexer multiplexer) { } + public System.Threading.Tasks.Task TryAcquireAsync(string key, string ownerToken, System.TimeSpan lease, System.Threading.CancellationToken cancellationToken) { throw null!; } + public System.Threading.Tasks.Task ReleaseAsync(string key, string ownerToken, System.Threading.CancellationToken cancellationToken) { throw null!; } + } + public sealed class McpIdempotencyOptions + { + public McpIdempotencyOptions() { } + public System.TimeSpan Lease { get { throw null!; } set { } } + public System.TimeSpan? PayloadTtl { get { throw null!; } set { } } + public System.TimeSpan AcquireWaitBudget { get { throw null!; } set { } } + public System.TimeSpan PollDelay { get { throw null!; } set { } } + } public sealed class NoOpRateLimiter : BuildingBlocks.Mcp.IMcpRateLimiter { public NoOpRateLimiter() { } @@ -219,6 +247,8 @@ namespace BuildingBlocks.Mcp public BuildingBlocks.Mcp.McpBuilder ScanAssemblyContaining() { throw null!; } public BuildingBlocks.Mcp.McpBuilder UseTelemetry(System.Action? configure = null) { throw null!; } public BuildingBlocks.Mcp.McpBuilder UseMemoryIdempotency(System.TimeSpan? timeToLive = null) { throw null!; } + public BuildingBlocks.Mcp.McpBuilder UseDistributedIdempotency(System.Action? configure = null) { throw null!; } + public BuildingBlocks.Mcp.McpBuilder UseRedisLock() { throw null!; } public BuildingBlocks.Mcp.McpBuilder UseStdioTransport() { throw null!; } public BuildingBlocks.Mcp.McpBuilder MapTool(string name, string description, System.Func>> handler, System.Action? configure = null) where TMessage : class { throw null!; } public BuildingBlocks.Mcp.McpBuilder MapTool(string name, string description, System.Func>> handler, System.Action? configure = null) where TMessage : class { throw null!; } @@ -227,6 +257,7 @@ namespace BuildingBlocks.Mcp public static class McpServiceCollectionExtensions { public static BuildingBlocks.Mcp.McpBuilder AddBuildingBlocksMcp(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Action? configure = null) { throw null!; } + public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddRedisMcpIdempotencyLock(this Microsoft.Extensions.DependencyInjection.IServiceCollection services) { throw null!; } public static Microsoft.Extensions.DependencyInjection.IServiceCollection AddMcpFeatureFlagEvaluator(this Microsoft.Extensions.DependencyInjection.IServiceCollection services, System.Func> evaluator) { throw null!; } } public sealed class FeatureFlagCallbackOptions diff --git a/src/BuildingBlocks/Mcp/PublicAPI.Unshipped.txt b/src/BuildingBlocks/Mcp/PublicAPI.Unshipped.txt index 7dc5c58..b3ce8a0 100644 --- a/src/BuildingBlocks/Mcp/PublicAPI.Unshipped.txt +++ b/src/BuildingBlocks/Mcp/PublicAPI.Unshipped.txt @@ -1 +1,2 @@ #nullable enable +# New public API since 1.1.0 — move into PublicAPI.Shipped.txt when cutting the next version. diff --git a/src/BuildingBlocks/Mcp/Redis/RedisMcpIdempotencyLock.cs b/src/BuildingBlocks/Mcp/Redis/RedisMcpIdempotencyLock.cs new file mode 100644 index 0000000..27b1130 --- /dev/null +++ b/src/BuildingBlocks/Mcp/Redis/RedisMcpIdempotencyLock.cs @@ -0,0 +1,72 @@ +using StackExchange.Redis; + +namespace BuildingBlocks.Mcp; + +/// +/// MCP-owned Redis lock for distributed write idempotency (SET NX PX acquire, owner-checked delete). +/// Uses the host (database 0). Not HTTP +/// IIdempotencyLock and not a generic distributed-lock package. +/// +public sealed class RedisMcpIdempotencyLock : IMcpIdempotencyLock +{ + private readonly IConnectionMultiplexer _multiplexer; + + /// Creates a lock over database 0. + public RedisMcpIdempotencyLock(IConnectionMultiplexer multiplexer) + { + _multiplexer = multiplexer ?? throw new ArgumentNullException(nameof(multiplexer)); + } + + /// + public async Task TryAcquireAsync(string key, string ownerToken, TimeSpan lease, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerToken); + if (lease <= TimeSpan.Zero) + throw new ArgumentOutOfRangeException(nameof(lease), lease, "Lease must be greater than zero."); + + cancellationToken.ThrowIfCancellationRequested(); + var database = _multiplexer.GetDatabase(); + var expiryMilliseconds = (int)lease.TotalMilliseconds; + if (expiryMilliseconds < 1) + throw new ArgumentOutOfRangeException(nameof(lease), lease, "Lease must be at least 1 millisecond."); + + const string script = """ + local result = redis.call('SET', KEYS[1], ARGV[1], 'NX', 'PX', ARGV[2]) + if result then + return true + else + return false + end + """; + + var result = (bool)await database.ScriptEvaluateAsync( + script, + [key], + [ownerToken, expiryMilliseconds]).ConfigureAwait(false); + return result; + } + + /// + public async Task ReleaseAsync(string key, string ownerToken, CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(key); + ArgumentException.ThrowIfNullOrWhiteSpace(ownerToken); + cancellationToken.ThrowIfCancellationRequested(); + var database = _multiplexer.GetDatabase(); + + const string script = """ + if redis.call('get', KEYS[1]) == ARGV[1] then + return redis.call('del', KEYS[1]) + else + return 0 + end + """; + + var result = (int)await database.ScriptEvaluateAsync( + script, + [key], + [ownerToken]).ConfigureAwait(false); + return result == 1; + } +} diff --git a/tests/BuildingBlocks/Mcp.Analyzers.Tests/McpToolAttributeAnalyzerTests.cs b/tests/BuildingBlocks/Mcp.Analyzers.Tests/McpToolAttributeAnalyzerTests.cs index 25d3e05..f3dd3d3 100644 --- a/tests/BuildingBlocks/Mcp.Analyzers.Tests/McpToolAttributeAnalyzerTests.cs +++ b/tests/BuildingBlocks/Mcp.Analyzers.Tests/McpToolAttributeAnalyzerTests.cs @@ -3,8 +3,13 @@ namespace BuildingBlocks.Mcp.Analyzers.Tests; +/// +/// Roslyn analyzer diagnostics for [McpTool] (compile-time catalog rules). +/// Not a protocol or FeatureFusion test. +/// public sealed class McpToolAttributeAnalyzerTests { + /// BBMCP001: Description is required so tools/list is self-describing. [Fact] public async Task BBMCP001_When_Description_Missing() { @@ -22,6 +27,7 @@ public sealed class {|#0:CreateOrder|} { } await AnalyzerTestHelper.VerifyAsync(source, expected); } + /// Idempotent is optional on commands; the analyzer does not require the flag. [Fact] public async Task NoDiagnostic_When_Command_Omits_Idempotent() { @@ -35,6 +41,7 @@ public sealed class CreateOrder { } await AnalyzerTestHelper.VerifyAsync(source); } + /// BBMCP003: two types with the same tool name are a catalog collision. [Fact] public async Task BBMCP003_When_Duplicate_Names() { @@ -55,6 +62,7 @@ public sealed class {|#1:Second|} { } await AnalyzerTestHelper.VerifyAsync(source, expected0, expected1); } + /// BBMCP004: tools must be concrete instantiable types (not abstract/interface). [Fact] public async Task BBMCP004_When_Attribute_On_Interface() { @@ -72,6 +80,7 @@ public abstract class {|#0:BadTool|} { } await AnalyzerTestHelper.VerifyAsync(source, expected); } + /// Happy path: a documented query type produces no diagnostic. [Fact] public async Task NoDiagnostic_When_Query_Has_Description() { @@ -85,6 +94,7 @@ public sealed class ListProducts { } await AnalyzerTestHelper.VerifyAsync(source); } + /// BBMCP005: instance methods cannot be tools (scan only public static methods). [Fact] public async Task BBMCP005_When_Attribute_On_Instance_Method() { diff --git a/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs b/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs index 1cee632..15124ec 100644 --- a/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs +++ b/tests/BuildingBlocks/Mcp.Tests/CatalogAndInvokerTests.cs @@ -11,8 +11,13 @@ namespace BuildingBlocks.Mcp.Tests; +/// +/// In-process catalog scan and tests (no Streamable HTTP, no FeatureFusion). +/// Wire-level 2026-07-28 MRTR lives in . +/// public sealed class CatalogAndInvokerTests { + /// Opt-in scan: unmarked types are not tools; duplicate names fail catalog uniqueness. [Fact] public void Scan_Ignores_Unmarked_And_Throws_On_Duplicate_Names() { @@ -26,6 +31,7 @@ public void Scan_Ignores_Unmarked_And_Throws_On_Duplicate_Names() ])); } + /// Schema generation: required vs optional, XML descriptions, named and numeric enums. [Fact] public void Schema_Optional_Defaults_Enums_And_Descriptions() { @@ -55,6 +61,7 @@ public void Schema_Optional_Defaults_Enums_And_Descriptions() Assert.Equal([0L, 1L], numeric.EnumValues); } + /// Queries are not write-idempotent even if someone later passes a key in arguments. [Fact] public void Query_Descriptor_Is_Not_Write_Idempotent() { @@ -63,6 +70,7 @@ public void Query_Descriptor_Is_Not_Write_Idempotent() Assert.False(d.Idempotent); } + /// Commands require an idempotency key even when Idempotent is left at the default. [Fact] public async Task Command_Without_Idempotent_Flag_Still_Requires_Key() { @@ -79,6 +87,7 @@ public async Task Command_Without_Idempotent_Flag_Still_Requires_Key() Assert.Equal(McpErrorCode.IdempotencyKeyRequired, missing.Error!.Code); } + /// Marking a query Idempotent does not consult the store — both calls dispatch. [Fact] public async Task Query_Ignores_Idempotency_Store() { @@ -98,6 +107,7 @@ public async Task Query_Ignores_Idempotency_Store() Assert.Equal(2, calls); } + /// UseMemoryIdempotency registers in DI. [Fact] public async Task UseMemoryIdempotency_Registers_Store() { @@ -115,6 +125,7 @@ public async Task UseMemoryIdempotency_Registers_Store() Assert.IsType(sp.GetRequiredService()); } + /// Scan + WithMcp on the same name yields one catalog entry, not a duplicate. [Fact] public async Task WithMcp_And_Scan_Dedupe_Same_Name() { @@ -133,6 +144,7 @@ public async Task WithMcp_And_Scan_Dedupe_Same_Name() Assert.Equal("pong:Ada", result.Value); } + /// Calling WithMcp without AddBuildingBlocksMcp must not throw at host start. [Fact] public async Task WithMcp_Without_AddBuildingBlocksMcp_Does_Not_Fail_Host_Start() { @@ -144,6 +156,7 @@ public async Task WithMcp_Without_AddBuildingBlocksMcp_Does_Not_Fail_Host_Start( await app.StopAsync(); } + /// GET endpoints mapped with WithMcp infer (not a write). [Fact] public async Task WithMcp_Named_Infers_Query_From_Get() { @@ -160,6 +173,7 @@ public async Task WithMcp_Named_Infers_Query_From_Get() public static string WithMcpNamedPing([AsParameters] EndpointPingRequest request) => $"pong:{request.Name}"; + /// Unknown tool names are NotFound; known tools round-trip the handler payload. [Fact] public async Task Invoke_RoundTrip_And_Deny_Unknown() { @@ -178,6 +192,7 @@ public async Task Invoke_RoundTrip_And_Deny_Unknown() Assert.Equal(McpErrorCode.NotFound, missing.Error!.Code); } + /// Missing key fails before the handler; the same key replays without a second dispatch. [Fact] public async Task Idempotent_Write_Requires_Key_And_Does_Not_Double_Dispatch() { @@ -208,6 +223,10 @@ public async Task Idempotent_Write_Requires_Key_And_Does_Not_Double_Dispatch() Assert.Equal(1, calls); } + /// + /// In-process invoker (not HTTP MRTR): unconfirmed RequireConfirmation is ConfirmationRequired JSON; + /// confirmed then cancelled is Timeout; non-object args are Validation. + /// [Fact] public async Task Confirmation_Timeout_And_Invalid_Args() { @@ -242,6 +261,7 @@ public async Task Confirmation_Timeout_And_Invalid_Args() Assert.Equal(McpErrorCode.Validation, bad.Error!.Code); } + /// A deny-all filter hides tools from list and returns Forbidden on invoke. [Fact] public async Task Filter_Hides_Tool_From_List_And_Invoke() { @@ -260,6 +280,7 @@ public async Task Filter_Hides_Tool_From_List_And_Invoke() Assert.Equal(McpErrorCode.Forbidden, call.Error!.Code); } + /// Default error mapping must not leak exception messages (or stacks) to the client. [Fact] public async Task Handler_Throw_Is_Internal_Without_Stack() { @@ -273,6 +294,7 @@ public async Task Handler_Throw_Is_Internal_Without_Stack() Assert.DoesNotContain("secret-stack", result.Error.Message); } + /// includeExceptionDetails is opt-in for diagnostics; still Internal, but the message is included. [Fact] public async Task Handler_Throw_Includes_Exception_Message_When_Details_Enabled() { @@ -302,6 +324,7 @@ public async Task Handler_Throw_Includes_Exception_Message_When_Details_Enabled( Assert.Contains(knownMessage, result.Error.Message); } + /// Write tools are not retried by the invoker resilience path (one handler call on throw). [Fact] public async Task Writes_Are_Not_Retried_By_Invoker() { @@ -322,6 +345,7 @@ await invoker.InvokeAsync( Assert.Equal(1, calls); } + /// Pagination envelope JSON uses items / nextCursor (MCP page contract). [Fact] public void McpPage_Has_Items_And_Cursor() { @@ -331,6 +355,7 @@ public void McpPage_Has_Items_And_Cursor() Assert.Contains("nextCursor", json); } + /// Dry-run is visible on the invoke context during the handler, then cleared from the accessor. [Fact] public async Task DryRun_Is_On_Context_And_Accessor_During_Invoke() { @@ -370,6 +395,7 @@ public async Task DryRun_Is_On_Context_And_Accessor_During_Invoke() Assert.Null(accessor.Current); } + /// Non-McpResult handler returns still map IsSuccess/Error/StatusCode (duck typing). [Fact] public void DuckTyped_Result_Maps_Failure() { @@ -406,6 +432,7 @@ private static IMcpInvoker CreateInvoker( includeExceptionDetails: false); } + /// MapTool handlers run in a scope so scoped services resolve under ValidateScopes. [Fact] public async Task MapTool_Scoped_Service_Resolves_When_ValidateScopes() { @@ -435,6 +462,7 @@ public async Task MapTool_Scoped_Service_Resolves_When_ValidateScopes() Assert.Equal("ok", result.Value); } + /// Rate-limiter deny becomes RateLimited with RetryAfterSeconds, before the handler. [Fact] public async Task RateLimiter_Deny_Is_RateLimited() { @@ -459,6 +487,7 @@ public async Task RateLimiter_Deny_Is_RateLimited() Assert.Equal(7, result.Error.RetryAfterSeconds); } + /// Idempotency cache key is namespaced by tool name; the same key on two tools both dispatch. [Fact] public async Task Idempotency_Keys_Are_Namespaced_Per_Tool() { @@ -493,6 +522,7 @@ public async Task Idempotency_Keys_Are_Namespaced_Per_Tool() Assert.Equal(1, callsB); } + /// Replay payload is stored JSON, so the second result.Value is a JsonElement. [Fact] public async Task Idempotency_Replay_Returns_JsonElement() { @@ -509,6 +539,7 @@ public async Task Idempotency_Replay_Returns_JsonElement() Assert.Contains("7", ((JsonElement)second.Value!).GetRawText(), StringComparison.Ordinal); } + /// Memory store entries expire; a get after TTL is a miss (not a forever cache). [Fact] public async Task Idempotency_Store_Honors_Ttl() { @@ -519,6 +550,9 @@ public async Task Idempotency_Store_Honors_Ttl() Assert.Null(await store.GetAsync("k", CancellationToken.None)); } + /// + /// In-process wait-and-replay (Exp 14 analogue): one handler, both callers succeed with the cached payload. + /// [Fact] public async Task Idempotency_Lock_Single_Dispatch_Under_Concurrency() { @@ -534,12 +568,14 @@ public async Task Idempotency_Lock_Single_Dispatch_Under_Concurrency() }, store); var args = JsonDocument.Parse("""{"qty":1,"idempotencyKey":"parallel"}""").RootElement; - await Task.WhenAll( + var results = await Task.WhenAll( invoker.InvokeAsync("tests.create", args, McpInvokeContext.None, CancellationToken.None), invoker.InvokeAsync("tests.create", args, McpInvokeContext.None, CancellationToken.None)); Assert.Equal(1, calls); + Assert.All(results, r => Assert.True(r.IsSuccess)); } + /// Public static methods with [McpTool] are catalogued and invokable (Minimal API style). [Fact] public async Task Scan_Public_Static_Method_Is_A_Tool() { diff --git a/tests/BuildingBlocks/Mcp.Tests/DistributedIdempotencyTests.cs b/tests/BuildingBlocks/Mcp.Tests/DistributedIdempotencyTests.cs new file mode 100644 index 0000000..166de8f --- /dev/null +++ b/tests/BuildingBlocks/Mcp.Tests/DistributedIdempotencyTests.cs @@ -0,0 +1,638 @@ +using System.Text.Json; +using BuildingBlocks.Mcp.Catalog; +using BuildingBlocks.Mcp.Invocation; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Xunit; + +namespace BuildingBlocks.Mcp.Tests; + +/// +/// Distributed MCP idempotency: wait-and-replay with . +/// Not HTTP Processing/409. Lease expiry is characterized, not claimed as exactly-once. +/// +public sealed class DistributedIdempotencyTests +{ + private const string WriteTool = "tests.create"; + private const string ConfirmTool = "tests.confirm-write"; + + /// Two invokers, shared store+lock, same key: one handler, both success, same payload. + [Fact] + public async Task Two_Invokers_Shared_Store_And_Lock_Replay_Same_Payload() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var a = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = 41 })); + }); + var b = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = 99 })); + }); + + var args = WriteArgs("k-shared"); + var first = await a.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + var second = await b.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + + Assert.True(first.IsSuccess); + Assert.True(second.IsSuccess); + Assert.Equal(1, calls); + Assert.Contains("41", ((JsonElement)second.Value!).GetRawText(), StringComparison.Ordinal); + Assert.Equal(1, gate.ReleaseCalls); + } + + /// Concurrent same key: one InvokeCore while the lease is valid; waiters replay. + [Fact] + public async Task Concurrent_Same_Key_Waiters_Replay_Without_Second_InvokeCore() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invoker = CreateInvoker(sp, store, async (_, _, _, _) => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(); + await release.Task; + return McpResult.Ok(new { Id = 7 }); + }); + + var args = WriteArgs("k-concurrent"); + var first = invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var secondTask = invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + await WaitUntilAsync(() => gate.AcquireAttempts >= 2, TimeSpan.FromSeconds(5)); + Assert.Equal(1, Volatile.Read(ref calls)); + release.TrySetResult(); + var results = await Task.WhenAll(first, secondTask); + Assert.Equal(1, calls); + Assert.All(results, r => Assert.True(r.IsSuccess)); + } + + /// Completed replay does not run the handler again. + [Fact] + public async Task Completed_Replay_Does_Not_Invoke_Handler() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = 3 })); + }); + var args = WriteArgs("k-replay"); + Assert.True((await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None)).IsSuccess); + Assert.True((await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None)).IsSuccess); + Assert.Equal(1, calls); + Assert.Equal(1, gate.AcquireSuccesses); + } + + /// Handler throw: no Set; a later call may execute. + [Fact] + public async Task Handler_Throw_Does_Not_Set_And_Later_Call_May_Execute() + { + var (store, _, sp) = CreateShared(); + var calls = 0; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + if (calls == 1) + throw new InvalidOperationException("boom"); + return Task.FromResult(McpResult.Ok(new { Id = 1 })); + }); + var args = WriteArgs("k-throw"); + var first = await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + Assert.Equal(McpErrorCode.Internal, first.Error!.Code); + Assert.Null(await store.GetAsync(InvokerCacheKey("k-throw"), CancellationToken.None)); + var second = await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + Assert.True(second.IsSuccess); + Assert.Equal(2, calls); + } + + /// Abandoned owner (vacated lock, no payload) lets another instance execute. + [Fact] + public async Task Abandoned_Owner_Expired_Lease_Allows_Another_Execution() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invoker = CreateInvoker(sp, store, async (_, _, _, _) => + { + var n = Interlocked.Increment(ref calls); + if (n == 1) + { + entered.TrySetResult(); + await release.Task; + } + + return McpResult.Ok(new { Id = n }); + }); + + var args = WriteArgs("k-abandon"); + var first = invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + gate.Vacate(); + var second = await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + release.TrySetResult(); + await first; + Assert.Equal(2, Volatile.Read(ref calls)); + Assert.True(second.IsSuccess); + } + + /// Cancel while holding the lock: Release runs; a waiter is not stuck. + [Fact] + public async Task Cancel_While_Holding_Lock_Releases_And_Waiter_Proceeds() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invoker = CreateInvoker(sp, store, async (_, _, _, ct) => + { + Interlocked.Increment(ref calls); + entered.TrySetResult(); + await Task.Delay(TimeSpan.FromSeconds(30), ct); + return McpResult.Ok(new { Id = 1 }); + }); + + using var cts = new CancellationTokenSource(); + var args = WriteArgs("k-cancel"); + var first = invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, cts.Token); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await cts.CancelAsync(); + var canceled = await first; + Assert.Equal(McpErrorCode.Canceled, canceled.Error!.Code); + Assert.Equal(1, gate.ReleaseCalls); + + var second = await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + Assert.True(second.IsSuccess); + Assert.Equal(2, Volatile.Read(ref calls)); + } + + /// + /// Lease-expiry overlap is allowed: vacating the lock while the first InvokeCore is running + /// admits a second execution. Not exactly-once. + /// + [Fact] + public async Task Lease_Expiry_Overlap_Allows_Two_Executions() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var invoker = CreateInvoker(sp, store, async (_, _, _, _) => + { + var n = Interlocked.Increment(ref calls); + if (n == 1) + { + entered.TrySetResult(); + await release.Task; + } + + return McpResult.Ok(new { Id = n }); + }); + + var args = WriteArgs("k-overlap"); + var first = invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + gate.Vacate(); + var second = await invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + release.TrySetResult(); + var firstResult = await first; + Assert.Equal(2, Volatile.Read(ref calls)); + Assert.True(firstResult.IsSuccess); + Assert.True(second.IsSuccess); + } + + /// Wrong owner cannot release; the original holder remains. + [Fact] + public async Task Wrong_Owner_Release_Returns_False_And_Leaves_Lock_Held() + { + var gate = new TestMcpIdempotencyLock(); + Assert.True(await gate.TryAcquireAsync("k", "owner-a", TimeSpan.FromMinutes(2), CancellationToken.None)); + Assert.False(await gate.ReleaseAsync("k", "owner-b", CancellationToken.None)); + Assert.False(await gate.TryAcquireAsync("k", "owner-c", TimeSpan.FromMinutes(2), CancellationToken.None)); + Assert.True(await gate.ReleaseAsync("k", "owner-a", CancellationToken.None)); + Assert.True(await gate.TryAcquireAsync("k", "owner-c", TimeSpan.FromMinutes(2), CancellationToken.None)); + } + + /// Lock acquire throw: Internal, handler never runs. + [Fact] + public async Task Acquire_Throw_Is_Internal_And_Does_Not_Invoke() + { + var (store, gate, sp) = CreateShared(); + gate.ThrowOnAcquire = true; + var calls = 0; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(1)); + }); + var result = await invoker.InvokeAsync(WriteTool, WriteArgs("k-lockfail"), McpInvokeContext.None, CancellationToken.None); + Assert.Equal(McpErrorCode.Internal, result.Error!.Code); + Assert.Equal(0, calls); + } + + /// Store Get failure before invoke is Internal; Set failure after success still returns the computed result. + [Fact] + public async Task Cache_Get_Failure_Is_Internal_Set_Failure_After_Success_Returns_Computed() + { + var gate = new TestMcpIdempotencyLock(); + var throwing = new ThrowingStore(); + throwing.ThrowOnGet = true; + var sp = BuildSp(throwing, gate); + var calls = 0; + var invoker = CreateInvoker(sp, throwing, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = 8 })); + }); + var getFail = await invoker.InvokeAsync(WriteTool, WriteArgs("k-get"), McpInvokeContext.None, CancellationToken.None); + Assert.Equal(McpErrorCode.Internal, getFail.Error!.Code); + Assert.Equal(0, calls); + + throwing.ThrowOnGet = false; + throwing.ThrowOnSet = true; + var setFail = await invoker.InvokeAsync(WriteTool, WriteArgs("k-set"), McpInvokeContext.None, CancellationToken.None); + Assert.True(setFail.IsSuccess); + Assert.Equal(1, calls); + } + + /// Different idempotency keys execute independently. + [Fact] + public async Task Different_Keys_Execute_Independently() + { + var (store, _, sp) = CreateShared(); + var calls = 0; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = calls })); + }); + Assert.True((await invoker.InvokeAsync(WriteTool, WriteArgs("k1"), McpInvokeContext.None, CancellationToken.None)).IsSuccess); + Assert.True((await invoker.InvokeAsync(WriteTool, WriteArgs("k2"), McpInvokeContext.None, CancellationToken.None)).IsSuccess); + Assert.Equal(2, calls); + } + + /// Queries never touch the store or lock. + [Fact] + public async Task Query_Does_Not_Touch_Store_Or_Lock() + { + var throwing = new ThrowingStore { ThrowOnGet = true, ThrowOnSet = true }; + var gate = new TestMcpIdempotencyLock { ThrowOnAcquire = true }; + var sp = BuildSp(throwing, gate); + var listed = McpToolScanner.FromType(typeof(ListedOrder), (_, _, _, _) => Task.FromResult(McpResult.Ok("ok"))); + var invoker = new McpInvoker( + [listed], + sp, + [], + new NoOpRateLimiter(), + new DefaultMcpResultMapper(), + dispatcher: null, + idempotency: throwing, + resilience: null, + telemetry: null, + includeExceptionDetails: false); + var result = await invoker.InvokeAsync( + "tests.list", + JsonDocument.Parse("""{"sku":"x"}""").RootElement, + McpInvokeContext.None, + CancellationToken.None); + Assert.True(result.IsSuccess); + Assert.Equal(0, gate.AcquireAttempts); + } + + /// Unconfirmed RequireConfirmation returns ConfirmationRequired with no store/lock I/O. + [Fact] + public async Task Unconfirmed_Does_Not_Touch_Store_Or_Lock() + { + var throwing = new ThrowingStore { ThrowOnGet = true, ThrowOnSet = true }; + var gate = new TestMcpIdempotencyLock { ThrowOnAcquire = true }; + var sp = BuildSp(throwing, gate); + var d = McpToolScanner.FromType(typeof(ConfirmWriteCommand), (_, _, _, _) => Task.FromResult(McpResult.Ok(1))); + var invoker = new McpInvoker( + [d], + sp, + [], + new NoOpRateLimiter(), + new DefaultMcpResultMapper(), + dispatcher: null, + idempotency: throwing, + resilience: null, + telemetry: null, + includeExceptionDetails: false); + var result = await invoker.InvokeAsync( + ConfirmTool, + JsonDocument.Parse("""{"qty":1,"idempotencyKey":"k-u"}""").RootElement, + McpInvokeContext.None, + CancellationToken.None); + Assert.Equal(McpErrorCode.ConfirmationRequired, result.Error!.Code); + Assert.Equal(0, gate.AcquireAttempts); + } + + /// Confirmed accept then a second call replays the distributed payload. + [Fact] + public async Task Confirmed_Accept_Then_Distributed_Replay() + { + var (store, _, sp) = CreateShared(); + var calls = 0; + var d = McpToolScanner.FromType(typeof(ConfirmWriteCommand), (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(new { Id = 12 })); + }); + var invoker = new McpInvoker( + [d], + sp, + [], + new NoOpRateLimiter(), + new DefaultMcpResultMapper(), + dispatcher: null, + idempotency: store, + resilience: null, + telemetry: null, + includeExceptionDetails: false); + var args = JsonDocument.Parse("""{"qty":1,"idempotencyKey":"k-ok","confirmed":true}""").RootElement; + Assert.True((await invoker.InvokeAsync(ConfirmTool, args, McpInvokeContext.None, CancellationToken.None)).IsSuccess); + var replay = await invoker.InvokeAsync(ConfirmTool, args, McpInvokeContext.None, CancellationToken.None); + Assert.True(replay.IsSuccess); + Assert.Equal(1, calls); + } + + /// + /// NEGATIVE: shared Get/Set without a lock (two invokers, process gates are not shared) + /// allows two handlers — cache-only storage is not enough. + /// + [Fact] + public async Task Shared_Store_Without_Lock_Allows_Two_Handlers() + { + var store = new MemoryIdempotencyStore(); + var calls = 0; + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + Task> Handler(IServiceProvider _, object __, McpInvokeContext ___, CancellationToken ____) + { + var n = Interlocked.Increment(ref calls); + if (n == 1) + { + entered.TrySetResult(); + return WaitAndOk(release); + } + + return Task.FromResult(McpResult.Ok(new { Id = n })); + } + + var empty = new ServiceCollection().BuildServiceProvider(); + var a = CreateInvoker(empty, store, Handler); + var b = CreateInvoker(empty, store, Handler); + var args = WriteArgs("k-neg"); + var first = a.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var second = await b.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None); + release.TrySetResult(); + await first; + Assert.Equal(2, Volatile.Read(ref calls)); + Assert.True(second.IsSuccess); + } + + /// Cancel before acquire: Canceled; lock is never taken. + [Fact] + public async Task Cancel_Before_Acquire_Is_Canceled_And_Does_Not_Lock() + { + var (store, gate, sp) = CreateShared(); + var calls = 0; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => + { + Interlocked.Increment(ref calls); + return Task.FromResult(McpResult.Ok(1)); + }); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + var result = await invoker.InvokeAsync(WriteTool, WriteArgs("k-pre"), McpInvokeContext.None, cts.Token); + Assert.Equal(McpErrorCode.Canceled, result.Error!.Code); + Assert.Equal(0, gate.AcquireAttempts); + Assert.Equal(0, calls); + } + + /// Release throw after a successful invoke must not turn success into failure. + [Fact] + public async Task Release_Failure_After_Success_Still_Returns_Computed() + { + var (store, gate, sp) = CreateShared(); + gate.ThrowOnRelease = true; + var invoker = CreateInvoker(sp, store, (_, _, _, _) => Task.FromResult(McpResult.Ok(new { Id = 4 }))); + var result = await invoker.InvokeAsync(WriteTool, WriteArgs("k-rel"), McpInvokeContext.None, CancellationToken.None); + Assert.True(result.IsSuccess); + Assert.Equal(1, gate.ReleaseCalls); + } + + /// Memory path (no IMcpIdempotencyLock): Exp 14-equivalent wait-and-replay on SemaphoreSlim. + [Fact] + public async Task Memory_Implementation_Preserves_Wait_And_Replay() + { + var store = new MemoryIdempotencyStore(); + var calls = 0; + var empty = new ServiceCollection().BuildServiceProvider(); + var invoker = CreateInvoker(empty, store, async (_, _, _, _) => + { + await Task.Delay(40); + Interlocked.Increment(ref calls); + return McpResult.Ok(new { Id = 1 }); + }); + var args = WriteArgs("k-mem"); + var results = await Task.WhenAll( + invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None), + invoker.InvokeAsync(WriteTool, args, McpInvokeContext.None, CancellationToken.None)); + Assert.Equal(1, calls); + Assert.All(results, r => Assert.True(r.IsSuccess)); + } + + /// UseDistributedIdempotency fails fast when no is registered. + [Fact] + public void UseDistributedIdempotency_Without_Lock_Throws_On_Invoker_Resolve() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + services.AddBuildingBlocksMcp(o => + { + o.MapTool( + "tests.dist", + "Dist", + (_, _, _) => Task.FromResult(McpResult.Ok(1)), + a => a.Kind = McpToolKind.Command); + o.UseDistributedIdempotency(); + }); + using var sp = services.BuildServiceProvider(); + var ex = Assert.Throws(() => sp.GetRequiredService()); + Assert.Contains("IMcpIdempotencyLock", ex.Message, StringComparison.Ordinal); + } + + /// Distributed store prefixes keys so they cannot collide with HTTP Idempotency_* entries. + [Fact] + public async Task Distributed_Store_Prefixes_Payload_Keys() + { + var services = new ServiceCollection(); + services.AddDistributedMemoryCache(); + await using var sp = services.BuildServiceProvider(); + var cache = sp.GetRequiredService(); + var store = new DistributedCacheIdempotencyStore(cache, TimeSpan.FromMinutes(5)); + await store.SetAsync("orders.create\u001fk1", """{"id":1}""", CancellationToken.None); + Assert.Equal("""{"id":1}""", await cache.GetStringAsync("mcp:idemp:orders.create\u001fk1")); + Assert.Equal( + "mcp:idemp:orders.create\u001fk1:lock", + McpDefaults.FormatIdempotencyLockKey("orders.create", "k1")); + } + + private static async Task> WaitAndOk(TaskCompletionSource release) + { + await release.Task; + return McpResult.Ok(new { Id = 1 }); + } + + private static (MemoryIdempotencyStore Store, TestMcpIdempotencyLock Gate, ServiceProvider Sp) CreateShared() + { + var store = new MemoryIdempotencyStore(); + var gate = new TestMcpIdempotencyLock(); + return (store, gate, BuildSp(store, gate)); + } + + private static ServiceProvider BuildSp(IMcpIdempotencyStore store, IMcpIdempotencyLock gate) + { + var services = new ServiceCollection(); + services.AddSingleton(store); + services.AddSingleton(gate); + services.AddSingleton(gate); + services.AddSingleton(new McpIdempotencyOptions + { + Lease = TimeSpan.FromMinutes(2), + AcquireWaitBudget = TimeSpan.FromSeconds(5), + PollDelay = TimeSpan.FromMilliseconds(5) + }); + return services.BuildServiceProvider(); + } + + private static McpInvoker CreateInvoker( + IServiceProvider sp, + IMcpIdempotencyStore store, + Func>> handler) + { + var d = McpToolScanner.FromType(typeof(CreateListedOrder), handler); + return new McpInvoker( + [d], + sp, + [], + new NoOpRateLimiter(), + new DefaultMcpResultMapper(), + dispatcher: null, + idempotency: store, + resilience: null, + telemetry: null, + includeExceptionDetails: true); + } + + private static JsonElement WriteArgs(string key) + => JsonDocument.Parse($$"""{"qty":1,"idempotencyKey":"{{key}}"}""").RootElement; + + private static string InvokerCacheKey(string clientKey) + => WriteTool + "\u001f" + clientKey; + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) + return; + await Task.Delay(5); + } + + throw new TimeoutException("Condition was not met."); + } +} + +[McpTool("tests.confirm-write", Description = "Confirm write", Kind = McpToolKind.Command, Idempotent = true, RequireConfirmation = true)] +public sealed class ConfirmWriteCommand +{ + public int Qty { get; set; } +} + +public sealed class ProbeWrite +{ + public int Qty { get; set; } +} + +/// Deterministic in-process lock for package tests. Vacate simulates lease expiry or crash. +internal sealed class TestMcpIdempotencyLock : IMcpIdempotencyLock +{ + private readonly object _sync = new(); + private readonly Dictionary _owners = new(StringComparer.Ordinal); + + public int AcquireAttempts; + public int AcquireSuccesses; + public int ReleaseCalls; + public bool ThrowOnAcquire; + public bool ThrowOnRelease; + + public void Vacate() + { + lock (_sync) + _owners.Clear(); + } + + public Task TryAcquireAsync(string key, string ownerToken, TimeSpan lease, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (ThrowOnAcquire) + throw new InvalidOperationException("lock-unavailable"); + + lock (_sync) + { + Interlocked.Increment(ref AcquireAttempts); + if (_owners.ContainsKey(key)) + return Task.FromResult(false); + _owners[key] = ownerToken; + Interlocked.Increment(ref AcquireSuccesses); + return Task.FromResult(true); + } + } + + public Task ReleaseAsync(string key, string ownerToken, CancellationToken cancellationToken) + { + lock (_sync) + { + Interlocked.Increment(ref ReleaseCalls); + if (ThrowOnRelease) + throw new InvalidOperationException("lock-release"); + if (!_owners.TryGetValue(key, out var owner) || !string.Equals(owner, ownerToken, StringComparison.Ordinal)) + return Task.FromResult(false); + _owners.Remove(key); + return Task.FromResult(true); + } + } +} + +internal sealed class ThrowingStore : IMcpIdempotencyStore +{ + private readonly MemoryIdempotencyStore _inner = new(); + public bool ThrowOnGet; + public bool ThrowOnSet; + + public Task GetAsync(string key, CancellationToken cancellationToken) + { + if (ThrowOnGet) + throw new InvalidOperationException("cache-get"); + return _inner.GetAsync(key, cancellationToken); + } + + public Task SetAsync(string key, string payloadJson, CancellationToken cancellationToken) + { + if (ThrowOnSet) + throw new InvalidOperationException("cache-set"); + return _inner.SetAsync(key, payloadJson, cancellationToken); + } +} diff --git a/tests/BuildingBlocks/Mcp.Tests/ProtocolMrtrHttpTests.cs b/tests/BuildingBlocks/Mcp.Tests/ProtocolMrtrHttpTests.cs new file mode 100644 index 0000000..c0186b9 --- /dev/null +++ b/tests/BuildingBlocks/Mcp.Tests/ProtocolMrtrHttpTests.cs @@ -0,0 +1,472 @@ +using System.Text; +using System.Text.Json; +using BuildingBlocks.Mcp; +using BuildingBlocks.Mcp.Hosting; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; + +namespace BuildingBlocks.Mcp.Tests; + +/// +/// Wire-level Streamable HTTP tests of BuildingBlocks.Mcp's custom CallTool adapter. +/// These tests speak the official MCP C# client; they are not FeatureFusion/Mediator tests. +/// +/// 2026-07-28: the adapter throws SDK InputRequiredException, which the server serializes as +/// resultType: input_required plus elicitation. The official client then auto-retries with +/// inputResponses / echoed requestState when +/// is set. Without that handler the client throws and does not surface — +/// capture HTTP bodies to assert the incomplete result. +/// +/// +/// 2025-11-25: IsMrtrSupported is false, so the same unconfirmed write stays a +/// tool error. confirmed: true skips MRTR on both revisions. +/// +/// The HTTP transport is the SDK default (stateless). requestState is an opaque echo, not a server session. +/// +public sealed class ProtocolMrtrHttpTests +{ + private const string WriteTool = "tests.confirm-write"; + private const string ReadTool = "tests.ping"; + private const string July2026 = "2026-07-28"; + private const string November2025 = "2025-11-25"; + + /// + /// 2026-07-28 unconfirmed write: the adapter emits input_required; + /// accept retries with inputResponses; MapTool runs once. + /// Wire capture is required because CallToolAsync does not return . + /// + [Fact] + public async Task July2026_Unconfirmed_Write_Sends_InputRequired_Then_Accept_Invokes_Once() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + var wire = new WireCapture(); + ElicitRequestParams? elicitation = null; + await using var client = await host.ConnectAsync(July2026Accepting(req => elicitation = req), wire); + + var result = await client.CallToolAsync( + WriteTool, + WriteArgs(qty: 3, Guid.NewGuid().ToString("D"), confirmed: false)); + + Assert.False(result.IsError ?? false); + Assert.Contains("3", GetText(result), StringComparison.Ordinal); + Assert.Equal(1, Volatile.Read(ref calls.Value)); + Assert.NotNull(elicitation); + Assert.Contains("Confirm", elicitation!.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal("input_required", ParseInputRequired(wire.ResponseBodies).ResultType); + Assert.Contains("inputResponses", wire.RequestBodies.Single(b => b.Contains("inputResponses", StringComparison.Ordinal)), StringComparison.Ordinal); + } + + /// + /// 2026-07-28 decline must follow elicitation on the wire (resultType: input_required). + /// After decline the tool error is and MapTool never runs. + /// A bare ConfirmationRequired without elicitation is the 2025-11-25 fallback and must fail this test. + /// + [Fact] + public async Task July2026_Unconfirmed_Write_Sends_InputRequired_Then_Decline_Does_Not_Invoke() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + var wire = new WireCapture(); + ElicitRequestParams? elicitation = null; + await using var client = await host.ConnectAsync(July2026Declining(req => elicitation = req), wire); + + var result = await client.CallToolAsync( + WriteTool, + WriteArgs(qty: 4, Guid.NewGuid().ToString("D"), confirmed: false)); + + Assert.NotNull(elicitation); + Assert.Equal("input_required", ParseInputRequired(wire.ResponseBodies).ResultType); + Assert.True(result.IsError ?? false); + Assert.Equal(McpErrorCode.ConfirmationRequired, ReadErrorCode(result)); + Assert.Equal(0, Volatile.Read(ref calls.Value)); + } + + /// + /// Stateless Streamable HTTP: first client has no ElicitationHandler, so CallToolAsync throws. + /// requestState is captured from the HTTP body and replayed on a new client with empty SessionId. + /// The server does not keep MRTR state between connections. + /// + [Fact] + public async Task July2026_InputRequired_Retry_On_A_New_Http_Client_Does_Not_Need_Server_Session() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + var key = Guid.NewGuid().ToString("D"); + var args = WriteJsonArgs(qty: 7, key, confirmed: false); + + string? requestState; + string inputKey; + var firstWire = new WireCapture(); + await using (var first = await host.ConnectAsync(July2026Options(), firstWire)) + { + Assert.True(string.IsNullOrEmpty(first.SessionId)); + // No ElicitationHandler: the SDK throws instead of returning InputRequiredResult. + await Assert.ThrowsAsync( + async () => await first.CallToolAsync(WriteTool, WriteArgs(qty: 7, key, confirmed: false))); + var incomplete = ParseInputRequired(firstWire.ResponseBodies); + Assert.Equal("input_required", incomplete.ResultType); + Assert.False(string.IsNullOrWhiteSpace(incomplete.RequestState)); + requestState = incomplete.RequestState; + inputKey = Assert.Single(incomplete.InputRequests!).Key; + Assert.Equal(0, Volatile.Read(ref calls.Value)); + } + + await using var second = await host.ConnectAsync(July2026Options()); + Assert.True(string.IsNullOrEmpty(second.SessionId)); + var completed = await second.CallToolAsync(new CallToolRequestParams + { + Name = WriteTool, + Arguments = args, + RequestState = requestState, + InputResponses = AcceptInput(inputKey) + }); + + Assert.False(completed.IsError ?? false); + Assert.Equal(1, Volatile.Read(ref calls.Value)); + } + + /// + /// 2025-11-25: IsMrtrSupported is false, so an unconfirmed write stays + /// JSON. No ElicitationHandler — MRTR is not negotiated. + /// + [Fact] + public async Task November2025_Unconfirmed_Write_Returns_ConfirmationRequired_Not_InputRequired() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + await using var client = await host.ConnectAsync(new McpClientOptions + { + ProtocolVersion = November2025 + }); + + var result = await client.CallToolAsync(WriteTool, WriteArgs(qty: 2, Guid.NewGuid().ToString("D"), confirmed: false)); + Assert.True(result.IsError ?? false); + Assert.Equal(McpErrorCode.ConfirmationRequired, ReadErrorCode(result)); + Assert.Equal(0, Volatile.Read(ref calls.Value)); + } + + /// + /// confirmed: true is the pre-MRTR skip on 2026-07-28 as well. + /// No ElicitationHandler: if the server emitted input_required, CallToolAsync would throw. + /// + [Fact] + public async Task July2026_Confirmed_True_Skips_InputRequired_And_Invokes() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + // No ElicitationHandler: success here proves the server did not emit input_required. + await using var client = await host.ConnectAsync(July2026Options()); + + var result = await client.CallToolAsync( + WriteTool, + WriteArgs(qty: 8, Guid.NewGuid().ToString("D"), confirmed: true)); + + Assert.False(result.IsError ?? false); + Assert.Equal(1, Volatile.Read(ref calls.Value)); + } + + /// + /// MemoryIdempotencyStore is keyed by tool + idempotencyKey, not by confirmed. + /// Accept (MRTR retry) then replay with confirmed: true must not invoke MapTool again. + /// + [Fact] + public async Task July2026_Accepted_Write_Then_Same_Idempotency_Key_Does_Not_Invoke_Twice() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + await using var client = await host.ConnectAsync(July2026Accepting()); + var key = Guid.NewGuid().ToString("D"); + + var first = await client.CallToolAsync(WriteTool, WriteArgs(qty: 5, key, confirmed: false)); + Assert.False(first.IsError ?? false); + Assert.Equal(1, Volatile.Read(ref calls.Value)); + + var replay = await client.CallToolAsync(WriteTool, WriteArgs(qty: 99, key, confirmed: true)); + Assert.False(replay.IsError ?? false); + Assert.Contains("5", GetText(replay), StringComparison.Ordinal); + Assert.Equal(1, Volatile.Read(ref calls.Value)); + } + + /// + /// Query tools are not RequireConfirmation. A 2026 ping must not elicit; + /// no ElicitationHandler is registered so a stray input_required would throw. + /// + [Fact] + public async Task July2026_Read_Tool_Does_Not_Use_InputRequired() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + await using var client = await host.ConnectAsync(July2026Options()); + + var result = await client.CallToolAsync(ReadTool, new Dictionary { ["name"] = "Ada" }); + Assert.False(result.IsError ?? false); + Assert.Contains("pong:Ada", GetText(result), StringComparison.Ordinal); + Assert.Equal(0, Volatile.Read(ref calls.Value)); + } + + /// + /// McpInvoker rejects a missing idempotency key before confirmation/MRTR. + /// Without a key there is no input_required and MapTool never runs. + /// + [Fact] + public async Task July2026_Missing_Idempotency_Key_Fails_Before_InputRequired() + { + var calls = new CallCounter(); + await using var host = await ProtocolHost.StartAsync(calls); + await using var client = await host.ConnectAsync(July2026Options()); + + var result = await client.CallToolAsync( + WriteTool, + new Dictionary { ["qty"] = 1 }); + + Assert.True(result.IsError ?? false); + Assert.Equal(McpErrorCode.IdempotencyKeyRequired, ReadErrorCode(result)); + Assert.Equal(0, Volatile.Read(ref calls.Value)); + } + + /// Pins 2026-07-28 with no ElicitationHandler (CallToolAsync throws on input_required). + private static McpClientOptions July2026Options() + => new() { ProtocolVersion = July2026 }; + + /// 2026 client that auto-accepts elicitation so CallToolAsync completes the MRTR retry. + private static McpClientOptions July2026Accepting(Action? onElicit = null) + => new() + { + ProtocolVersion = July2026, + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, _) => + { + if (request is not null) + onElicit?.Invoke(request); + return ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + [McpDefaults.ConfirmedArgument] = JsonSerializer.SerializeToElement(true) + } + }); + } + } + }; + + /// 2026 client that declines elicitation; CallToolAsync then surfaces ConfirmationRequired. + private static McpClientOptions July2026Declining(Action? onElicit = null) + => new() + { + ProtocolVersion = July2026, + Handlers = new McpClientHandlers + { + ElicitationHandler = (request, _) => + { + if (request is not null) + onElicit?.Invoke(request); + return ValueTask.FromResult(new ElicitResult { Action = "decline" }); + } + } + }; + + private static Dictionary WriteArgs(int qty, string key, bool confirmed) + { + var args = new Dictionary + { + ["qty"] = qty, + [McpDefaults.IdempotencyKeyArgument] = key + }; + if (confirmed) + args[McpDefaults.ConfirmedArgument] = true; + return args; + } + + private static Dictionary WriteJsonArgs(int qty, string key, bool confirmed) + { + var args = new Dictionary + { + ["qty"] = JsonSerializer.SerializeToElement(qty), + [McpDefaults.IdempotencyKeyArgument] = JsonSerializer.SerializeToElement(key) + }; + if (confirmed) + args[McpDefaults.ConfirmedArgument] = JsonSerializer.SerializeToElement(true); + return args; + } + + /// Builds the inputResponses map a second HTTP client would send after capturing requestState. + private static Dictionary AcceptInput(string inputKey) + => new() + { + [inputKey] = InputResponse.FromElicitResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + [McpDefaults.ConfirmedArgument] = JsonSerializer.SerializeToElement(true) + } + }) + }; + + /// + /// Reads resultType: input_required out of captured JSON-RPC/SSE bodies. + /// The official client's CallToolAsync resolves MRTR internally and does not return . + /// + private static InputRequiredResult ParseInputRequired(IEnumerable bodies) + { + foreach (var body in bodies) + { + foreach (var json in EnumerateJsonPayloads(body)) + { + using var doc = JsonDocument.Parse(json); + if (doc.RootElement.ValueKind != JsonValueKind.Object) + continue; + if (!doc.RootElement.TryGetProperty("result", out var result)) + continue; + if (!result.TryGetProperty("resultType", out var resultType) + || resultType.GetString() != "input_required") + continue; + return result.Deserialize(McpJsonUtilities.DefaultOptions) + ?? throw new InvalidOperationException("Failed to deserialize InputRequiredResult."); + } + } + + throw new InvalidOperationException("No input_required JSON-RPC result was captured."); + } + + private static IEnumerable EnumerateJsonPayloads(string body) + { + var trimmed = body.Trim(); + if (trimmed.StartsWith('{')) + { + yield return trimmed; + yield break; + } + + foreach (var line in body.Split('\n')) + { + var data = line.Trim(); + if (data.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + yield return data["data:".Length..].Trim(); + } + } + + private static string GetText(CallToolResult result) + => string.Join("\n", result.Content.OfType().Select(b => b.Text)); + + private static McpErrorCode ReadErrorCode(CallToolResult result) + { + using var doc = JsonDocument.Parse(GetText(result)); + var code = doc.RootElement.GetProperty("code"); + if (code.ValueKind == JsonValueKind.Number) + return (McpErrorCode)code.GetInt32(); + return Enum.Parse(code.GetString()!); + } + + /// Counts MapTool handler executions, not HTTP round trips. + internal sealed class CallCounter + { + public int Value; + } +} + +/// +/// Copies Streamable HTTP request/response bodies. The official client hides input_required +/// behind ElicitationHandler auto-retry, so protocol assertions read these captures. +/// +file sealed class WireCapture : DelegatingHandler +{ + public List RequestBodies { get; } = []; + public List ResponseBodies { get; } = []; + + public WireCapture() + : base(new SocketsHttpHandler()) + { + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (request.Content is not null) + RequestBodies.Add(await request.Content.ReadAsStringAsync(cancellationToken)); + + var response = await base.SendAsync(request, cancellationToken); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); + ResponseBodies.Add(Encoding.UTF8.GetString(bytes)); + + var copy = new ByteArrayContent(bytes); + foreach (var header in response.Content.Headers) + copy.Headers.TryAddWithoutValidation(header.Key, header.Value); + response.Content = copy; + return response; + } +} + +/// +/// In-process Kestrel + MapBuildingBlocksMcp only. Not FeatureFusion, not Mediator. +/// +file sealed class ProtocolHost : IAsyncDisposable +{ + private readonly WebApplication _app; + + private ProtocolHost(WebApplication app) => _app = app; + + public static async Task StartAsync(ProtocolMrtrHttpTests.CallCounter calls) + { + var builder = WebApplication.CreateSlimBuilder(); + builder.WebHost.UseUrls("http://127.0.0.1:0"); + builder.Services.AddBuildingBlocksMcp(o => + { + o.UseMemoryIdempotency(TimeSpan.FromHours(1)); + o.MapTool( + "tests.confirm-write", + "Confirm write", + (msg, _, _) => + { + Interlocked.Increment(ref calls.Value); + return Task.FromResult(McpResult.Ok(msg.Qty)); + }, + a => + { + a.Kind = McpToolKind.Command; + a.Idempotent = true; + a.RequireConfirmation = true; + }); + o.MapTool( + "tests.ping", + "Ping", + (msg, _, _) => Task.FromResult(McpResult.Ok(string.IsNullOrWhiteSpace(msg.Name) ? "pong" : $"pong:{msg.Name}")), + a => a.Kind = McpToolKind.Query); + }); + + var app = builder.Build(); + app.MapBuildingBlocksMcp(); + await app.StartAsync(); + return new ProtocolHost(app); + } + + public async Task ConnectAsync(McpClientOptions options, WireCapture? wire = null) + { + HttpMessageHandler pipeline = wire is not null ? wire : new SocketsHttpHandler(); + var http = new HttpClient(pipeline) { BaseAddress = new Uri(_app.Urls.Single()) }; + var transport = new HttpClientTransport( + new HttpClientTransportOptions { Endpoint = new Uri(http.BaseAddress, "mcp") }, + http, + ownsHttpClient: true); + return await McpClient.CreateAsync(transport, options); + } + + public async ValueTask DisposeAsync() => await _app.DisposeAsync(); +} + +file sealed class ConfirmWrite +{ + public int Qty { get; set; } +} + +file sealed class PingQuery +{ + public string? Name { get; set; } +} diff --git a/tests/BuildingBlocks/Mcp.Tests/ProtocolRegistrationTests.cs b/tests/BuildingBlocks/Mcp.Tests/ProtocolRegistrationTests.cs index e1c8abb..42c0ac6 100644 --- a/tests/BuildingBlocks/Mcp.Tests/ProtocolRegistrationTests.cs +++ b/tests/BuildingBlocks/Mcp.Tests/ProtocolRegistrationTests.cs @@ -7,8 +7,13 @@ namespace BuildingBlocks.Mcp.Tests; +/// +/// Adapter mapping from BuildingBlocks.Mcp descriptors to official SDK Tool / CallTool results. +/// Not a live HTTP protocol test — see for Streamable HTTP MRTR. +/// public sealed class ProtocolRegistrationTests { + /// Catalog resource URI matching is case-insensitive and allows a trailing slash. [Theory] [InlineData("catalog://tools")] [InlineData("catalog://tools/")] @@ -16,6 +21,7 @@ public sealed class ProtocolRegistrationTests public void Catalog_Uri_Accepts_Trailing_Slash_And_Case(string uri) => Assert.True(McpProtocolRegistration.IsCatalogResourceUri(uri)); + /// Non-catalog URIs are not treated as the tools catalog resource. [Theory] [InlineData(null)] [InlineData("")] @@ -23,6 +29,7 @@ public void Catalog_Uri_Accepts_Trailing_Slash_And_Case(string uri) public void Catalog_Uri_Rejects_Unknown(string? uri) => Assert.False(McpProtocolRegistration.IsCatalogResourceUri(uri)); + /// Input schema carries enum names, descriptions, and omits optional properties from required. [Fact] public void ToTool_Schema_Has_Enums_Optional_And_Descriptions() { @@ -45,6 +52,7 @@ public void ToTool_Schema_Has_Enums_Optional_And_Descriptions() Assert.DoesNotContain("named", names); } + /// Idempotent commands advertise idempotencyKey as a required UUID in the MCP schema. [Fact] public void ToTool_Idempotent_Command_Advertises_Uuid_Key() { @@ -63,6 +71,7 @@ public void ToTool_Idempotent_Command_Advertises_Uuid_Key() Assert.Contains("idempotencyKey", doc.RootElement.GetProperty("required").EnumerateArray().Select(e => e.GetString())); } + /// Primitive string results are wrapped as StructuredContent { value } for MCP clients. [Fact] public void Success_String_Payload_Wraps_StructuredContent_As_Object() { @@ -72,6 +81,7 @@ public void Success_String_Payload_Wraps_StructuredContent_As_Object() Assert.Equal("pong:Ada", result.StructuredContent!.Value.GetProperty("value").GetString()); } + /// Object payloads stay objects in StructuredContent (not double-wrapped). [Fact] public void Success_Object_Payload_Keeps_StructuredContent_As_Object() { @@ -80,6 +90,7 @@ public void Success_Object_Payload_Keeps_StructuredContent_As_Object() Assert.Equal("hello-mcp", result.StructuredContent!.Value.GetProperty("echo").GetString()); } + /// Queries are not write-idempotent, so the schema must not require idempotencyKey. [Fact] public void ToTool_Query_Omits_Idempotency_Key() { diff --git a/tests/BuildingBlocks/Mcp.Tests/RedisMcpIdempotencyLockTests.cs b/tests/BuildingBlocks/Mcp.Tests/RedisMcpIdempotencyLockTests.cs new file mode 100644 index 0000000..255ad65 --- /dev/null +++ b/tests/BuildingBlocks/Mcp.Tests/RedisMcpIdempotencyLockTests.cs @@ -0,0 +1,230 @@ +using System.Collections.Concurrent; +using System.Reflection; +using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; +using Xunit; + +namespace BuildingBlocks.Mcp.Tests; + +/// +/// SET NX PX acquire and owner-checked release. +/// In-memory Redis script stand-in — not HTTP IIdempotencyLock. +/// +public sealed class RedisMcpIdempotencyLockTests +{ + [Fact] + public async Task Acquire_succeeds_when_key_is_absent() + { + var (lockObj, store) = Create(); + Assert.True(await lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromSeconds(5), CancellationToken.None)); + Assert.Equal("owner-a", store.Owner("k")); + } + + [Fact] + public async Task Acquire_fails_when_another_owner_holds_the_key() + { + var (lockObj, _) = Create(); + Assert.True(await lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromSeconds(30), CancellationToken.None)); + Assert.False(await lockObj.TryAcquireAsync("k", "owner-b", TimeSpan.FromSeconds(30), CancellationToken.None)); + } + + [Fact] + public async Task Acquire_succeeds_after_lease_expiry() + { + var (lockObj, store) = Create(); + Assert.True(await lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromMilliseconds(40), CancellationToken.None)); + await Task.Delay(80); + Assert.True(await lockObj.TryAcquireAsync("k", "owner-b", TimeSpan.FromSeconds(5), CancellationToken.None)); + Assert.Equal("owner-b", store.Owner("k")); + } + + [Fact] + public async Task Release_succeeds_for_owner_and_fails_for_wrong_owner() + { + var (lockObj, store) = Create(); + Assert.True(await lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromSeconds(30), CancellationToken.None)); + Assert.False(await lockObj.ReleaseAsync("k", "owner-b", CancellationToken.None)); + Assert.Equal("owner-a", store.Owner("k")); + Assert.True(await lockObj.ReleaseAsync("k", "owner-a", CancellationToken.None)); + Assert.Null(store.Owner("k")); + } + + [Fact] + public async Task Canceled_token_throws_before_redis() + { + var (lockObj, store) = Create(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => + lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromSeconds(5), cts.Token)); + await Assert.ThrowsAnyAsync(() => + lockObj.ReleaseAsync("k", "owner-a", cts.Token)); + Assert.Null(store.Owner("k")); + } + + [Fact] + public async Task Redis_error_propagates() + { + var mux = DispatchProxy.Create(); + var lockObj = new RedisMcpIdempotencyLock(mux); + await Assert.ThrowsAsync(() => + lockObj.TryAcquireAsync("k", "owner-a", TimeSpan.FromSeconds(5), CancellationToken.None)); + } + + [Fact] + public void Constructor_rejects_null_multiplexer() + => Assert.Throws(() => new RedisMcpIdempotencyLock(null!)); + + [Fact] + public async Task Rejects_invalid_key_owner_and_lease() + { + var (lockObj, _) = Create(); + await Assert.ThrowsAsync(() => + lockObj.TryAcquireAsync(" ", "owner", TimeSpan.FromSeconds(1), CancellationToken.None)); + await Assert.ThrowsAsync(() => + lockObj.TryAcquireAsync("k", " ", TimeSpan.FromSeconds(1), CancellationToken.None)); + await Assert.ThrowsAsync(() => + lockObj.TryAcquireAsync("k", "owner", TimeSpan.Zero, CancellationToken.None)); + } + + [Fact] + public void UseRedisLock_registers_mcp_lock_from_host_multiplexer() + { + var store = new InMemoryRedisLockStore(); + var database = DispatchProxy.Create(); + ((ScriptDatabase)(object)database).Store = store; + var connection = DispatchProxy.Create(); + ((ScriptMux)(object)connection).Database = database; + + var services = new ServiceCollection(); + services.AddSingleton(connection); + services.AddDistributedMemoryCache(); + services.AddBuildingBlocksMcp(o => + { + o.MapTool( + "tests.redis-lock", + "Probe", + (_, _, _) => Task.FromResult(McpResult.Ok(new { })), + a => a.Kind = McpToolKind.Query); + o.UseDistributedIdempotency(); + o.UseRedisLock(); + }); + using var sp = services.BuildServiceProvider(); + Assert.IsType(sp.GetRequiredService()); + Assert.NotNull(sp.GetRequiredService()); + } + + private static (RedisMcpIdempotencyLock Lock, InMemoryRedisLockStore Store) Create() + { + var store = new InMemoryRedisLockStore(); + var database = DispatchProxy.Create(); + ((ScriptDatabase)(object)database).Store = store; + var mux = DispatchProxy.Create(); + ((ScriptMux)(object)mux).Database = database; + return (new RedisMcpIdempotencyLock(mux), store); + } +} + +internal sealed class InMemoryRedisLockStore +{ + private readonly ConcurrentDictionary _locks = new(StringComparer.Ordinal); + + public string? Owner(string key) + { + if (!_locks.TryGetValue(key, out var entry)) + return null; + if (entry.ExpiresAtTicks <= DateTime.UtcNow.Ticks) + { + _locks.TryRemove(key, out _); + return null; + } + + return entry.Owner; + } + + public bool TrySetNx(string key, string owner, int expiryMilliseconds) + { + var expires = DateTime.UtcNow.AddMilliseconds(expiryMilliseconds).Ticks; + while (true) + { + if (_locks.TryGetValue(key, out var existing)) + { + if (existing.ExpiresAtTicks > DateTime.UtcNow.Ticks) + return false; + if (_locks.TryUpdate(key, (owner, expires), existing)) + return true; + continue; + } + + if (_locks.TryAdd(key, (owner, expires))) + return true; + } + } + + public bool TryRelease(string key, string owner) + { + if (!_locks.TryGetValue(key, out var existing)) + return false; + if (existing.ExpiresAtTicks <= DateTime.UtcNow.Ticks) + { + _locks.TryRemove(key, out _); + return false; + } + + if (!string.Equals(existing.Owner, owner, StringComparison.Ordinal)) + return false; + return _locks.TryRemove(key, out _); + } +} + +internal class ScriptDatabase : DispatchProxy +{ + public InMemoryRedisLockStore Store { get; set; } = new(); + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == nameof(IDatabase.ScriptEvaluateAsync) + && args is { Length: >= 3 } + && args[0] is string script) + { + var keys = args[1] as RedisKey[] ?? []; + var values = args[2] as RedisValue[] ?? []; + return EvaluateAsync(script, keys, values); + } + + throw new NotSupportedException(targetMethod?.Name); + } + + private Task EvaluateAsync(string script, RedisKey[] keys, RedisValue[] values) + { + var key = (string)keys[0]!; + if (script.Contains("SET", StringComparison.Ordinal) && script.Contains("NX", StringComparison.Ordinal)) + { + var owner = (string)values[0]!; + var expiry = (int)values[1]; + var acquired = Store.TrySetNx(key, owner, expiry); + return Task.FromResult(RedisResult.Create(acquired)); + } + + var released = Store.TryRelease(key, (string)values[0]!); + return Task.FromResult(RedisResult.Create(released ? 1 : 0)); + } +} + +internal class ScriptMux : DispatchProxy +{ + public IDatabase Database { get; set; } = null!; + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + if (targetMethod?.Name == nameof(IConnectionMultiplexer.GetDatabase)) + return Database; + throw new NotSupportedException(targetMethod?.Name); + } +} + +internal class ThrowingMux : DispatchProxy +{ + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + => throw new InvalidOperationException("redis-unavailable"); +} diff --git a/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs b/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs index 0cd21fe..91e6f79 100644 --- a/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs +++ b/tests/Lab/IntegrationTests/Api/CatalogProductHttpMcpConvergenceTests.cs @@ -31,6 +31,7 @@ public CatalogProductHttpMcpConvergenceTests(AspireFixture fixture) }); } + /// HTTP and MCP list the same Demo Commerce catalog page (shared Mediator query, not confirmation). [Fact] public async Task Http_and_mcp_list_catalog_products() { @@ -46,6 +47,7 @@ public async Task Http_and_mcp_list_catalog_products() mcpResult.StructuredContent.Should().NotBeNull(); } + /// Flagship product fields match across HTTP GET and MCP catalog.product.get. [Fact] public async Task Http_and_mcp_return_same_flagship_product() { @@ -77,6 +79,7 @@ public async Task Http_and_mcp_return_same_flagship_product() mcpDetail.Specifications.Should().HaveCount(httpDetail.Specifications.Count); } + /// Unknown slug is a tool error on MCP (same domain outcome as HTTP 404, different envelope). [Fact] public async Task Mcp_unknown_slug_is_error() { diff --git a/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs b/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs index fa8fb0f..61388e4 100644 --- a/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs +++ b/tests/Lab/IntegrationTests/Api/FeatureFusionMcpTests.cs @@ -11,6 +11,9 @@ namespace IntegrationTests.Api; /// /// Live Streamable HTTP MCP against FeatureFusion /mcp (same Aspire + WAF fixture as API smoke). +/// Unpinned — SDK probes protocol version (typically 2026-07-28). +/// These are catalog/smoke checks, not MRTR protocol tests. Writes that must skip elicitation pass +/// confirmed: true. Confirmation-revision experiments live in McpMrtrConfirmation and Exp 6. /// [Collection(AspireCollection.Name)] public sealed class FeatureFusionMcpTests @@ -25,6 +28,9 @@ public FeatureFusionMcpTests(AspireFixture fixture) }); } + /// + /// Deny-by-default catalog: only tools registered on FeatureFusion appear; unmarked/void methods do not. + /// [Fact] public async Task Tools_List_Contains_Opt_In_Tools_Only() { @@ -48,6 +54,7 @@ public async Task Tools_List_Contains_Opt_In_Tools_Only() names.Should().NotContain(n => n.Contains("void", StringComparison.OrdinalIgnoreCase)); } + /// Query smoke: demo.echo is not RequireConfirmation and must not elicit. [Fact] public async Task Call_Demo_Echo_Succeeds() { @@ -61,6 +68,11 @@ public async Task Call_Demo_Echo_Succeeds() text.Should().Contain("hello-mcp"); } + /// + /// Write gate smoke: missing both confirmed and idempotency key must fail before CreateOrderCommand. + /// McpInvoker checks the key first, so 2026 typically returns IdempotencyKeyRequired (not input_required). + /// Protocol-native elicitation is McpMrtrConfirmation, not this test. + /// [Fact] public async Task Call_Orders_Create_Without_Confirm_And_Key_Is_Error() { @@ -83,6 +95,9 @@ public async Task Call_Orders_Create_Without_Confirm_And_Key_Is_Error() || t.Contains("idempotency", StringComparison.OrdinalIgnoreCase)); } + /// + /// confirmed: true plus a fresh idempotency key skips elicitation on both 2025 and 2026 and Creates an order. + /// [Fact] public async Task Call_Orders_Create_With_Confirm_And_Key_Succeeds() { @@ -106,6 +121,7 @@ public async Task Call_Orders_Create_With_Confirm_And_Key_Succeeds() || t.Contains("id", StringComparison.OrdinalIgnoreCase)); } + /// Advertised products.list schema: sort enums and optional cursor/limit (pagination, not confirmation). [Fact] public async Task Products_List_Schema_Has_Enums_And_Optional_Cursor() { @@ -132,6 +148,7 @@ public async Task Products_List_Schema_Has_Enums_And_Optional_Cursor() } } + /// MCP 2025+ structured content: echo payload is both text and StructuredContent. [Fact] public async Task Call_Demo_Echo_Includes_StructuredContent() { @@ -146,6 +163,7 @@ public async Task Call_Demo_Echo_Includes_StructuredContent() result.StructuredContent!.Value.GetRawText().Should().Contain("hello-mcp"); } + /// Lab catalog resource (catalog://tools) lists the same opt-in tools as tools/list. [Fact] public async Task Catalog_Resource_Lists_Lab_Tools() { @@ -160,6 +178,9 @@ public async Task Catalog_Resource_Lists_Lab_Tools() markdown.Should().Contain("lab.ping"); } + /// + /// Minimal-API [McpTool] method (lab.ping) is a query; 2026 without ElicitationHandler must still succeed. + /// [Fact] public async Task Call_Lab_Ping_From_Minimal_Api_Method_Succeeds() { diff --git a/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs b/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs index ad156c5..f4d4aeb 100644 --- a/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/MafMcpPrototype/MafMcpPrototypeTests.cs @@ -69,6 +69,7 @@ public MafMcpPrototypeTests(AspireFixture fixture, ITestOutputHelper output) }); } + /// Transport-only: official client can list FeatureFusion tools without an LLM (no elicitation involved). [Fact] public async Task Maf_mcp_transport_connects_and_lists_featurefusion_tools() { @@ -79,6 +80,10 @@ public async Task Maf_mcp_transport_connects_and_lists_featurefusion_tools() names.Should().Contain(["demo.echo", "products.list", OrdersCreateTool, "lab.ping"]); } + /// + /// Live agent: MAF drives FeatureFusion /mcp and records the tool sequence. + /// Agent instructions set confirmed=true so this spike does not exercise MRTR elicitation. + /// [Fact] public async Task Maf_agent_runs_goal_and_records_observed_tool_sequence() { diff --git a/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs index 25c91b5..e4aeee7 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpAgentKeyRegeneration/McpAgentKeyRegenerationExperimentTests.cs @@ -55,6 +55,10 @@ public McpAgentKeyRegenerationExperimentTests(AspireFixture fixture, ITestOutput }); } + /// + /// Exp 13: same-key replay does not amplify; a new idempotency key is a new CreateOrder. + /// Confirmed writes only — regenerated keys are not an elicitation/MRTR concern. + /// [Fact] public async Task Agent_regenerated_idempotency_keys_amplify_mcp_writes_and_downstream_work() { diff --git a/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs index 3298ddb..88fc751 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpConcurrentSameKey/McpConcurrentSameKeyExperimentTests.cs @@ -54,6 +54,10 @@ public McpConcurrentSameKeyExperimentTests(AspireFixture fixture, ITestOutputHel }); } + /// + /// Exp 14: concurrent same-key confirmed writes collapse to one business operation. + /// Confirmation is skipped with confirmed: true so the race is about the idempotency lock, not MRTR. + /// [Fact] public async Task Concurrent_same_key_mcp_write_produces_exactly_one_business_operation() { diff --git a/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyExperimentTests.cs new file mode 100644 index 0000000..c8ab5d6 --- /dev/null +++ b/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyExperimentTests.cs @@ -0,0 +1,204 @@ +using System.Text.Json; +using BuildingBlocks.Mcp; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Mcp; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit.Abstractions; +using static IntegrationTests.Infrastructure.Telemetry.LabTrace; + +namespace IntegrationTests.Experiments.McpDistributedIdempotency; + +/// +/// Lab prototype (not a numbered Exp 21): FeatureFusion orders.create with distributed +/// MCP idempotency (IDistributedCache payloads + MCP ). +/// Default Program.cs remains UseMemoryIdempotency. This host is WithWebHostBuilder-only. +/// Hypothesis: wait-and-replay holds across two WAF instances sharing Aspire Redis while the lease +/// is valid; lease expiry can overlap (not exactly-once); MRTR unconfirmed does not write the store. +/// +[Collection(AspireCollection.Name)] +public sealed class McpDistributedIdempotencyExperimentTests : IDisposable +{ + private const string ToolName = "orders.create"; + private const int ProductId = 1; + private const int CustomerId = 1; + private const int Quantity = 2; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly AspireFixture _fixture; + private readonly WebApplicationFactory _factoryA; + private readonly WebApplicationFactory _factoryB; + private readonly HttpClient _httpA; + private readonly HttpClient _httpB; + private readonly ITestOutputHelper _output; + + public McpDistributedIdempotencyExperimentTests(AspireFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _factoryA = fixture.WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureDistributed)); + _factoryB = fixture.WithWebHostBuilder(builder => builder.ConfigureTestServices(ConfigureDistributed)); + _httpA = _factoryA.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + _httpB = _factoryB.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + /// Miss on factory A, confirmed replay on factory B returns the same order id without a second create. + [Fact] + public async Task Cross_factory_replay_returns_same_order() + { + await _fixture.ResetLabObservationAsync(); + using var capture = new InProcessActivityCapture(); + var key = System.Ulid.NewUlid().ToString(); + await using var mcpA = await LabMcpClient.CreateJuly2026Async(_httpA); + await using var mcpB = await LabMcpClient.CreateJuly2026Async(_httpB); + var before = capture.All.Count(IsMediator); + + var first = await mcpA.CallToolAsync(ToolName, OrderArgs(key, confirmed: true)); + (first.IsError ?? false).Should().BeFalse(McpToolResults.GetText(first)); + var order = McpToolResults.TryParseOrder(first, JsonOptions); + order.Should().NotBeNull(); + var afterFirst = capture.All.Count(IsMediator); + afterFirst.Should().BeGreaterThan(before); + + var replay = await mcpB.CallToolAsync(ToolName, OrderArgs(key, confirmed: true)); + (replay.IsError ?? false).Should().BeFalse(McpToolResults.GetText(replay)); + var replayed = McpToolResults.TryParseOrder(replay, JsonOptions); + replayed.Should().NotBeNull(); + replayed!.OrderId.Should().Be(order!.OrderId); + capture.All.Count(IsMediator).Should().Be(afterFirst); + _output.WriteLine(McpToolResults.GetText(replay)); + } + + /// Concurrent confirmed writes with one key across two HTTP clients converge to one order while the lease is valid. + [Fact] + public async Task Concurrent_same_key_across_factories_is_one_order_while_lease_valid() + { + await _fixture.ResetLabObservationAsync(); + var key = System.Ulid.NewUlid().ToString(); + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var tasks = Enumerable.Range(0, 3).Select(async i => + { + await gate.Task; + var http = i % 2 == 0 ? _httpA : _httpB; + await using var mcp = await LabMcpClient.CreateJuly2026Async(http); + return await mcp.CallToolAsync(ToolName, OrderArgs(key, confirmed: true)); + }).ToArray(); + gate.SetResult(); + var results = await Task.WhenAll(tasks); + + results.Should().OnlyContain(r => !(r.IsError ?? false)); + var ids = results + .Select(r => McpToolResults.TryParseOrder(r, JsonOptions)?.OrderId) + .Where(id => id is { } g && g != Guid.Empty) + .Distinct() + .ToList(); + ids.Should().HaveCount(1); + } + + /// + /// 2026 unconfirmed elicitation/decline must not persist an idempotency payload; + /// a later confirmed call on the other factory may execute (no exactly-once before accept). + /// Accepted create then cross-factory confirmed replay shares the order id. + /// + [Fact] + public async Task Mrtr_unconfirmed_does_not_write_store_accept_then_replays_across_factory() + { + await _fixture.ResetLabObservationAsync(); + var declineKey = System.Ulid.NewUlid().ToString(); + ElicitRequestParams? elicitation = null; + await using var declining = await LabMcpClient.CreateJuly2026Async(_httpA, Decline(req => elicitation = req)); + var declined = await declining.CallToolAsync(ToolName, OrderArgs(declineKey, confirmed: false)); + elicitation.Should().NotBeNull(); + (declined.IsError ?? false).Should().BeTrue(); + + await using var other = await LabMcpClient.CreateJuly2026Async(_httpB); + var afterDecline = await other.CallToolAsync(ToolName, OrderArgs(declineKey, confirmed: true)); + (afterDecline.IsError ?? false).Should().BeFalse(McpToolResults.GetText(afterDecline)); + McpToolResults.TryParseOrder(afterDecline, JsonOptions).Should().NotBeNull(); + + var acceptKey = System.Ulid.NewUlid().ToString(); + await using var accepting = await LabMcpClient.CreateJuly2026Async(_httpA, Accept()); + var created = await accepting.CallToolAsync(ToolName, OrderArgs(acceptKey, confirmed: false)); + (created.IsError ?? false).Should().BeFalse(McpToolResults.GetText(created)); + var order = McpToolResults.TryParseOrder(created, JsonOptions); + order.Should().NotBeNull(); + + var replay = await other.CallToolAsync(ToolName, OrderArgs(acceptKey, confirmed: true)); + McpToolResults.TryParseOrder(replay, JsonOptions)!.OrderId.Should().Be(order!.OrderId); + } + + public void Dispose() + { + _httpA.Dispose(); + _httpB.Dispose(); + _factoryA.Dispose(); + _factoryB.Dispose(); + } + + private static void ConfigureDistributed(IServiceCollection services) + { + services.RemoveAll(); + services.AddSingleton(new McpIdempotencyOptions + { + Lease = TimeSpan.FromMinutes(2), + PayloadTtl = TimeSpan.FromHours(1), + AcquireWaitBudget = TimeSpan.FromSeconds(30), + PollDelay = TimeSpan.FromMilliseconds(20) + }); + services.AddSingleton(sp => + new DistributedCacheIdempotencyStore( + sp.GetRequiredService(), + TimeSpan.FromHours(1))); + services.AddSingleton(sp => + new RedisMcpIdempotencyLock(sp.GetRequiredService())); + } + + private static Dictionary OrderArgs(string idempotencyKey, bool confirmed) + { + var args = new Dictionary + { + ["productId"] = ProductId, + ["quantity"] = Quantity, + ["customerId"] = CustomerId, + [McpDefaults.IdempotencyKeyArgument] = idempotencyKey + }; + if (confirmed) + args[McpDefaults.ConfirmedArgument] = true; + return args; + } + + private static McpClientHandlers Accept() + => new() + { + ElicitationHandler = (_, _) => ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + [McpDefaults.ConfirmedArgument] = JsonSerializer.SerializeToElement(true) + } + }) + }; + + private static McpClientHandlers Decline(Action? onElicit) + => new() + { + ElicitationHandler = (request, _) => + { + if (request is not null) + onElicit?.Invoke(request); + return ValueTask.FromResult(new ElicitResult { Action = "decline" }); + } + }; +} diff --git a/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyLeaseOverlapExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyLeaseOverlapExperimentTests.cs new file mode 100644 index 0000000..7a83a72 --- /dev/null +++ b/tests/Lab/IntegrationTests/Experiments/McpDistributedIdempotency/McpDistributedIdempotencyLeaseOverlapExperimentTests.cs @@ -0,0 +1,159 @@ +using BuildingBlocks.Mcp; +using BuildingBlocks.Mediator; +using FeatureFusion.Features.Orders.Commands; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Mcp; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using ModelContextProtocol.Client; +using Xunit.Abstractions; + +namespace IntegrationTests.Experiments.McpDistributedIdempotency; + +/// +/// Characterization (not a safety claim): if the Redis lock lease expires while CreateOrderCommand +/// is still running, a second MCP client may execute production. No lease renewal in 1.1.0. +/// Isolated WithWebHostBuilder host; default Lab memory idempotency is unchanged. +/// +[Collection(AspireCollection.Name)] +public sealed class McpDistributedIdempotencyLeaseOverlapExperimentTests : IDisposable +{ + private const string ToolName = "orders.create"; + private static readonly TimeSpan TestLease = TimeSpan.FromSeconds(1); + + private readonly AspireFixture _fixture; + private readonly WebApplicationFactory _factory; + private readonly HttpClient _http; + private readonly CreateOrderLeaseHoldGate _gate; + private readonly ITestOutputHelper _output; + + public McpDistributedIdempotencyLeaseOverlapExperimentTests(AspireFixture fixture, ITestOutputHelper output) + { + _fixture = fixture; + _output = output; + _gate = new CreateOrderLeaseHoldGate(); + _factory = fixture.WithWebHostBuilder(builder => + { + builder.ConfigureTestServices(services => + { + services.RemoveAll(); + services.AddSingleton(new McpIdempotencyOptions + { + Lease = TestLease, + PayloadTtl = TimeSpan.FromHours(1), + AcquireWaitBudget = TimeSpan.FromSeconds(10), + PollDelay = TimeSpan.FromMilliseconds(20) + }); + services.AddSingleton(sp => + new DistributedCacheIdempotencyStore( + sp.GetRequiredService(), + TimeSpan.FromHours(1))); + services.AddSingleton(sp => + new RedisMcpIdempotencyLock(sp.GetRequiredService())); + services.AddSingleton(_gate); + services.RemoveAll>>(); + services.AddScoped(); + services.AddScoped>>(sp => + new GatedCreateOrderCommandHandler( + sp.GetRequiredService(), + sp.GetRequiredService())); + }); + }); + _http = _factory.CreateClient(new WebApplicationFactoryClientOptions { AllowAutoRedirect = false }); + } + + /// + /// After the lock lease expires, a second confirmed call may run CreateOrder while the first is held. + /// Records overlap; does not assert exactly-once. + /// + [Fact] + public async Task Expired_lock_lease_may_admit_overlapping_create_order() + { + await _fixture.ResetLabObservationAsync(); + _gate.Reset(); + var key = System.Ulid.NewUlid().ToString(); + var args = new Dictionary + { + ["productId"] = 1, + ["quantity"] = 2, + ["customerId"] = 1, + [McpDefaults.IdempotencyKeyArgument] = key, + [McpDefaults.ConfirmedArgument] = true + }; + + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http); + var firstTask = mcp.CallToolAsync(ToolName, args); + await _gate.FirstEntered.WaitAsync(TimeSpan.FromSeconds(30)); + await Task.Delay(TestLease + TimeSpan.FromSeconds(1)); + + await using var mcp2 = await LabMcpClient.CreateJuly2026Async(_http); + var second = await mcp2.CallToolAsync(ToolName, args); + _gate.ReleaseFirst.TrySetResult(); + var first = await firstTask.AsTask().WaitAsync(TimeSpan.FromSeconds(30)); + + _output.WriteLine( + $"handlerEntries={_gate.HandlerEntries}; firstError={first.IsError}; secondError={second.IsError}"); + _gate.HandlerEntries.Should().BeGreaterThanOrEqualTo(2, + "lease expiry is allowed to overlap production; this experiment characterizes that window"); + } + + public void Dispose() + { + _http.Dispose(); + _factory.Dispose(); + } + + public sealed class CreateOrderLeaseHoldGate + { + private int _handlerEntries; + private TaskCompletionSource _firstEntered = NewTcs(); + private TaskCompletionSource _releaseFirst = NewTcs(); + + public Task FirstEntered => _firstEntered.Task; + public TaskCompletionSource ReleaseFirst => _releaseFirst; + public int HandlerEntries => Volatile.Read(ref _handlerEntries); + + public void Reset() + { + Volatile.Write(ref _handlerEntries, 0); + _firstEntered = NewTcs(); + _releaseFirst = NewTcs(); + } + + public async Task WaitIfFirstAsync() + { + var n = Interlocked.Increment(ref _handlerEntries); + if (n == 1) + { + _firstEntered.TrySetResult(); + await _releaseFirst.Task.ConfigureAwait(false); + } + } + + private static TaskCompletionSource NewTcs() + => new(TaskCreationOptions.RunContinuationsAsynchronously); + } + + private sealed class GatedCreateOrderCommandHandler + : ICommandHandler> + { + private readonly CreateOrderCommandHandler _inner; + private readonly CreateOrderLeaseHoldGate _gate; + + public GatedCreateOrderCommandHandler(CreateOrderCommandHandler inner, CreateOrderLeaseHoldGate gate) + { + _inner = inner; + _gate = gate; + } + + public async Task> Handle(CreateOrderCommand request, CancellationToken cancellationToken) + { + await _gate.WaitIfFirstAsync().ConfigureAwait(false); + return await _inner.Handle(request, cancellationToken).ConfigureAwait(false); + } + } +} diff --git a/tests/Lab/IntegrationTests/Experiments/McpMrtrConfirmation/McpMrtrConfirmationExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpMrtrConfirmation/McpMrtrConfirmationExperimentTests.cs new file mode 100644 index 0000000..453a6f7 --- /dev/null +++ b/tests/Lab/IntegrationTests/Experiments/McpMrtrConfirmation/McpMrtrConfirmationExperimentTests.cs @@ -0,0 +1,240 @@ +using System.Text.Json; +using BuildingBlocks.Mcp; +using FluentAssertions; +using IntegrationTests.Aspire; +using IntegrationTests.Infrastructure.Mcp; +using IntegrationTests.Infrastructure.Telemetry; +using Microsoft.AspNetCore.Mvc.Testing; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit.Abstractions; +using static IntegrationTests.Infrastructure.Telemetry.LabTrace; + +namespace IntegrationTests.Experiments.McpMrtrConfirmation; + +/// +/// Lab prototype (not a numbered Exp): FeatureFusion application integration of MCP 2026-07-28 +/// confirmation MRTR. Protocol wire shape is proven in BuildingBlocks.Mcp.Tests.ProtocolMrtrHttpTests; +/// this suite asks whether the existing Mediator orders.create path is the only execution path. +/// +/// Hypothesis: a 2026 client without confirmed: true elicits confirmation; accept reaches +/// CreateOrderCommand once; decline never Sends; the same idempotency key does not write twice; +/// a 2025-11-25 client still uses the Exp 6 ConfirmationRequired / confirmed: true fallback. +/// The host is SDK-stateless Streamable HTTP — requestState is echoed, not a FeatureFusion session. +/// +/// +[Collection(AspireCollection.Name)] +public sealed class McpMrtrConfirmationExperimentTests +{ + private const string ToolName = "orders.create"; + private const int ProductId = 1; + private const int CustomerId = 1; + private const int Quantity = 2; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly HttpClient _http; + private readonly ITestOutputHelper _output; + + public McpMrtrConfirmationExperimentTests(AspireFixture fixture, ITestOutputHelper output) + { + _output = output; + _http = fixture.CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + } + + /// + /// A — 2026-07-28 accept: elicitation must fire (not a bare ConfirmationRequired). + /// After accept, FeatureFusion Sends CreateOrderCommand and returns an order id. + /// + [Fact] + public async Task A_July2026_Accept_After_Elicitation_Reaches_CreateOrderCommand() + { + using var capture = new InProcessActivityCapture(); + ElicitRequestParams? elicitation = null; + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http, AcceptConfirmation(req => elicitation = req)); + var before = MediatorCount(capture); + + var completed = await mcp.CallToolAsync( + ToolName, + OrderArgs(System.Ulid.NewUlid().ToString(), confirmed: false)); + + (completed.IsError ?? false).Should().BeFalse(McpToolResults.GetText(completed)); + elicitation.Should().NotBeNull("2026 unconfirmed writes must elicit; ConfirmationRequired alone is the 2025 fallback"); + elicitation!.Message.Should().Contain("Confirm"); + var order = McpToolResults.TryParseOrder(completed, JsonOptions); + order.Should().NotBeNull(); + order!.OrderId.Should().NotBeEmpty(); + MediatorCount(capture).Should().BeGreaterThan(before); + _output.WriteLine(McpToolResults.GetText(completed)); + } + + /// + /// B — 2026-07-28 decline: must run first. + /// Then the tool error is ConfirmationRequired and Mediator span count does not increase. + /// Passing on ConfirmationRequired alone would be the 2025 fallback. + /// + [Fact] + public async Task B_July2026_Decline_After_Elicitation_Does_Not_Send_CreateOrderCommand() + { + using var capture = new InProcessActivityCapture(); + ElicitRequestParams? elicitation = null; + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http, DeclineConfirmation(req => elicitation = req)); + var before = MediatorCount(capture); + + var declined = await mcp.CallToolAsync( + ToolName, + OrderArgs(System.Ulid.NewUlid().ToString(), confirmed: false)); + + elicitation.Should().NotBeNull("decline must follow input_required elicitation, not a bare ConfirmationRequired error"); + (declined.IsError ?? false).Should().BeTrue(); + McpToolResults.TryReadJsonErrorCode(declined).Should().Be(nameof(McpErrorCode.ConfirmationRequired)); + MediatorCount(capture).Should().Be(before); + } + + /// + /// C — 2025-11-25 FeatureFusion path (same pin as Exp 6): unconfirmed stays ConfirmationRequired JSON; + /// confirmed: true still Sends. No ElicitationHandler because MRTR is not negotiated. + /// + [Fact] + public async Task C_November2025_Unconfirmed_Stays_ConfirmationRequired_Confirmed_True_Still_Sends() + { + using var capture = new InProcessActivityCapture(); + await using var mcp = await LabMcpClient.CreateNovember2025Async(_http); + var before = MediatorCount(capture); + var result = await mcp.CallToolAsync( + ToolName, + OrderArgs(System.Ulid.NewUlid().ToString(), confirmed: false)); + + (result.IsError ?? false).Should().BeTrue(); + McpToolResults.TryReadJsonErrorCode(result).Should().Be(nameof(McpErrorCode.ConfirmationRequired)); + MediatorCount(capture).Should().Be(before); + + var confirmed = await mcp.CallToolAsync( + ToolName, + OrderArgs(System.Ulid.NewUlid().ToString(), confirmed: true)); + (confirmed.IsError ?? false).Should().BeFalse(McpToolResults.GetText(confirmed)); + MediatorCount(capture).Should().BeGreaterThan(before); + } + + /// + /// D — after an accepted 2026 elicitation, replaying the same MemoryIdempotencyStore key + /// (even with confirmed: true) returns the same order id and does not Send again. + /// + [Fact] + public async Task D_July2026_Same_Idempotency_Key_After_Accepted_Elicitation_Does_Not_Write_Twice() + { + using var capture = new InProcessActivityCapture(); + ElicitRequestParams? elicitation = null; + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http, AcceptConfirmation(req => elicitation = req)); + var key = System.Ulid.NewUlid().ToString(); + var before = MediatorCount(capture); + + var first = await mcp.CallToolAsync(ToolName, OrderArgs(key, confirmed: false)); + (first.IsError ?? false).Should().BeFalse(McpToolResults.GetText(first)); + elicitation.Should().NotBeNull(); + var order = McpToolResults.TryParseOrder(first, JsonOptions); + order.Should().NotBeNull(); + var afterAccept = MediatorCount(capture); + afterAccept.Should().BeGreaterThan(before); + + var replay = await mcp.CallToolAsync(ToolName, OrderArgs(key, confirmed: true)); + (replay.IsError ?? false).Should().BeFalse(McpToolResults.GetText(replay)); + var replayed = McpToolResults.TryParseOrder(replay, JsonOptions); + replayed.Should().NotBeNull(); + replayed!.OrderId.Should().Be(order!.OrderId); + MediatorCount(capture).Should().Be(afterAccept); + } + + /// + /// E — FeatureFusion /mcp is SDK-stateless Streamable HTTP. + /// Empty SessionId after accept shows confirmation is not stored in a transport session. + /// Protocol-level requestState replay is in ProtocolMrtrHttpTests. + /// + [Fact] + public async Task E_July2026_Accepted_CreateOrder_Does_Not_Require_An_Mcp_Session() + { + using var capture = new InProcessActivityCapture(); + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http, AcceptConfirmation()); + mcp.SessionId.Should().BeNullOrEmpty("stateless Streamable HTTP has no transport session to hold MRTR state"); + var before = MediatorCount(capture); + + var completed = await mcp.CallToolAsync( + ToolName, + OrderArgs(System.Ulid.NewUlid().ToString(), confirmed: false)); + + (completed.IsError ?? false).Should().BeFalse(McpToolResults.GetText(completed)); + mcp.SessionId.Should().BeNullOrEmpty(); + MediatorCount(capture).Should().BeGreaterThan(before); + } + + /// + /// Control: lab.ping is a query. A 2026 client without ElicitationHandler must still succeed + /// (a stray input_required would throw). + /// + [Fact] + public async Task Read_Only_Lab_Ping_Is_Unaffected() + { + await using var mcp = await LabMcpClient.CreateJuly2026Async(_http); + var result = await mcp.CallToolAsync( + "lab.ping", + new Dictionary { ["name"] = "Ada" }); + (result.IsError ?? false).Should().BeFalse(); + McpToolResults.GetText(result).Should().Contain("pong:Ada"); + } + + private static Dictionary OrderArgs(string idempotencyKey, bool confirmed) + { + var args = new Dictionary + { + ["productId"] = ProductId, + ["quantity"] = Quantity, + ["customerId"] = CustomerId, + [McpDefaults.IdempotencyKeyArgument] = idempotencyKey + }; + if (confirmed) + args[McpDefaults.ConfirmedArgument] = true; + return args; + } + + /// + /// Official-client elicitation callback. CallToolAsync auto-retries with inputResponses after this returns. + /// + private static McpClientHandlers AcceptConfirmation(Action? onElicit = null) + => new() + { + ElicitationHandler = (request, _) => + { + if (request is not null) + onElicit?.Invoke(request); + return ValueTask.FromResult(new ElicitResult + { + Action = "accept", + Content = new Dictionary + { + [McpDefaults.ConfirmedArgument] = JsonSerializer.SerializeToElement(true) + } + }); + } + }; + + /// ElicitationHandler that declines so CallToolAsync surfaces ConfirmationRequired without Send. + private static McpClientHandlers DeclineConfirmation(Action? onElicit = null) + => new() + { + ElicitationHandler = (request, _) => + { + if (request is not null) + onElicit?.Invoke(request); + return ValueTask.FromResult(new ElicitResult { Action = "decline" }); + } + }; + + private static int MediatorCount(InProcessActivityCapture capture) + => capture.All.Count(IsMediator); +} diff --git a/tests/Lab/IntegrationTests/Experiments/McpOrderIdempotency/McpOrderIdempotencyExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpOrderIdempotency/McpOrderIdempotencyExperimentTests.cs index b33efbb..ebcfacf 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpOrderIdempotency/McpOrderIdempotencyExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpOrderIdempotency/McpOrderIdempotencyExperimentTests.cs @@ -23,6 +23,8 @@ namespace IntegrationTests.Experiments.McpOrderIdempotency; /// CreateOrderCommand, same-key/different-args behavior, and fresh execution on a new key. /// Does not re-prove HTTP Redis idempotency (Exp 3), outbox/async handler delivery (Exp 5), /// or gateway behavior. +/// Down-level client: pinned to MCP 2025-11-25 so Unconfirmed still observes +/// ConfirmationRequired. Protocol-native MRTR is the McpMrtrConfirmation lab experiment. /// [Collection(AspireCollection.Name)] public sealed class McpOrderIdempotencyExperimentTests @@ -50,6 +52,12 @@ public McpOrderIdempotencyExperimentTests(AspireFixture fixture, ITestOutputHelp }); } + /// + /// Five labeled calls on a pinned 2025-11-25 client: Unconfirmed (ConfirmationRequired JSON), + /// ConfirmedMiss, SameKeyReplay, SameKeyDifferentQuantity, NewKeyFresh. + /// The pin is required: a 2026 client would elicit (input_required) instead of returning + /// ConfirmationRequired on Unconfirmed — that protocol path is McpMrtrConfirmation, not Exp 6. + /// [Fact] public async Task Mcp_orders_create_confirmation_and_memory_idempotency_are_observed() { @@ -61,7 +69,8 @@ public async Task Mcp_orders_create_confirmation_and_memory_idempotency_are_obse "traceparent", FormatTraceParent(transportTraceId, transportSpanId)); - await using var mcp = await LabMcpClient.CreateAsync(_http); + // 2025-11-25: Unconfirmed must stay ConfirmationRequired JSON. A 2026 client would get input_required instead (see McpMrtrConfirmation). + await using var mcp = await LabMcpClient.CreateNovember2025Async(_http); var seenToolTraces = new HashSet(StringComparer.Ordinal); var calls = new List(); var cachedKey = System.Ulid.NewUlid().ToString(); diff --git a/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs index 22d2c1a..f96d05f 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpOrderOutbox/McpOrderOutboxExperimentTests.cs @@ -60,6 +60,10 @@ public McpOrderOutboxExperimentTests(AspireFixture fixture, ITestOutputHelper ou }); } + /// + /// Exp 10: confirmed MCP orders.create cache miss follows outbox → handler; same-key replay does not. + /// Uses confirmed: true so elicitation is not part of this experiment (see McpMrtrConfirmation). + /// [Fact] public async Task Mcp_confirmed_orders_create_follows_outbox_to_handler_pipeline_and_replay_skips_async_work() { diff --git a/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs index cfa5fed..810aab4 100644 --- a/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/McpToolStormRateLimit/McpToolStormRateLimitExperimentTests.cs @@ -73,6 +73,10 @@ public McpToolStormRateLimitExperimentTests(AspireFixture fixture, ITestOutputHe }); } + /// + /// Exp 16: distinct-key storm hits before Mediator. + /// Writes use confirmed: true; this is not an MRTR or idempotency experiment. + /// [Fact] public async Task Distinct_key_mcp_write_storm_is_bounded_by_rate_limiter_before_production() { diff --git a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/McpPaginationAbuseExperimentTests.cs b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/McpPaginationAbuseExperimentTests.cs index 9a2ab15..a6e150a 100644 --- a/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/McpPaginationAbuseExperimentTests.cs +++ b/tests/Lab/IntegrationTests/Experiments/PaginationAbuse/McpPaginationAbuseExperimentTests.cs @@ -42,6 +42,10 @@ public McpPaginationAbuseExperimentTests(AspireFixture fixture, ITestOutputHelpe }); } + /// + /// Exp 2 observation: walk / replay / tamper / malformed cursor on MCP products.list + /// (query tool — no confirmation or MRTR). + /// [Fact] public async Task Cursor_abuse_against_mcp_products_list_is_observed() { diff --git a/tests/Lab/IntegrationTests/Experiments/README.md b/tests/Lab/IntegrationTests/Experiments/README.md index 87707d3..020e39c 100644 --- a/tests/Lab/IntegrationTests/Experiments/README.md +++ b/tests/Lab/IntegrationTests/Experiments/README.md @@ -100,6 +100,17 @@ Ollama is a Lab research convenience, not a FeatureFusion dependency. Choose a m This is research infrastructure only — not Exp 15 and not a BuildingBlock. +### Distributed MCP idempotency (BuildingBlocks.Mcp 1.1.0 Lab overlay) + +**Status:** Package-shipped in **BuildingBlocks.Mcp 1.1.0**. Lab host overlay only — default `Program.cs` stays on `UseMemoryIdempotency`. No second AppHost. + +[`McpDistributedIdempotency/`](McpDistributedIdempotency/) uses Exp 16’s `WithWebHostBuilder` isolation: two WAF instances share Aspire Redis (`IDistributedCache` payloads + MCP `RedisMcpIdempotencyLock` on the host `IConnectionMultiplexer`). Wait-and-replay across factories; concurrent same-key; MRTR unconfirmed does not write the store; lease-expiry overlap is characterized (not exactly-once). `BuildingBlocks.Mcp` does not reference `BuildingBlocks.Idempotency`. + +```bash +dotnet test tests/Lab/IntegrationTests/IntegrationTests.csproj \ + --filter "FullyQualifiedName~McpDistributedIdempotency" +``` + ### Workstream status — HTTP Redis idempotency (Exp 3, 4, 12) **COMPLETE — BuildingBlocks.Idempotency 1.0.1** (extraction evidence from 1.0.0; packaging/STJ polish in 1.0.1) @@ -437,6 +448,8 @@ Experiments/ McpToolStormRateLimit/ ← Exp 16 (MCP distinct-key storm + IMcpRateLimiter) ProcessedMessageDeduplication/ ← Exp 17 (EnableDeduplication + processed_messages) AsyncTraceCorrelation/ ← Exp 18 (HTTP→outbox→consumer TraceId correlation) + McpDistributedIdempotency/ ← prototype (Redis wait-and-replay; not Exp 21) + MafMcpPrototype/ ← MAF spike (not numbered) Infrastructure/ ← shared observation helpers (not experiments) Telemetry/ diff --git a/tests/Lab/IntegrationTests/Infrastructure/Mcp/LabMcpClient.cs b/tests/Lab/IntegrationTests/Infrastructure/Mcp/LabMcpClient.cs index a119912..5c4a13e 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/Mcp/LabMcpClient.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/Mcp/LabMcpClient.cs @@ -2,15 +2,69 @@ namespace IntegrationTests.Infrastructure.Mcp; +/// +/// Official MCP C# client against FeatureFusion /mcp. +/// +/// leaves unset +/// (SDK probes and prefers 2026-07-28). MRTR and down-level confirmation tests must pin the revision +/// explicitly via or . +/// +/// public static class LabMcpClient { - public static async Task CreateAsync(HttpClient http) + /// + /// MCP 2025-11-25: initialize handshake, no MRTR. + /// Unconfirmed RequireConfirmation writes return ConfirmationRequired JSON, not input_required. + /// + public const string November2025ProtocolVersion = "2025-11-25"; + + /// + /// MCP 2026-07-28: stateless Streamable HTTP. + /// Unconfirmed RequireConfirmation writes return MRTR input_required when the server throws + /// InputRequiredException. The official client auto-retries only if + /// is registered; otherwise it throws and does not + /// surface InputRequiredResult. + /// + public const string July2026ProtocolVersion = "2026-07-28"; + + /// + /// Unpinned protocol: the SDK probes and typically selects 2026-07-28. + /// Use or when the revision is part of the assertion. + /// + public static Task CreateAsync(HttpClient http) + => CreateAsync(http, clientOptions: null); + + /// + /// Pins 2026-07-28 so tests do not depend on the SDK default-version probe. + /// Pass handlers when the call is expected to elicit (accept/decline confirmation). + /// + public static Task CreateJuly2026Async(HttpClient http, McpClientHandlers? handlers = null) + { + var options = new McpClientOptions { ProtocolVersion = July2026ProtocolVersion }; + if (handlers is not null) + options.Handlers = handlers; + return CreateAsync(http, options); + } + + /// + /// Pins 2025-11-25 so MRTR is not negotiated. + /// Exp 6 Unconfirmed must keep observing ConfirmationRequired; a 2026 client would get input_required instead. + /// + public static Task CreateNovember2025Async(HttpClient http) + => CreateAsync(http, new McpClientOptions { ProtocolVersion = November2025ProtocolVersion }); + + /// + /// Connects the official C# client to FeatureFusion /mcp. + /// Null options leave unset (SDK default probe). + /// Do not set to null — the SDK setter throws. + /// + public static async Task CreateAsync(HttpClient http, McpClientOptions? clientOptions) { var endpoint = new Uri(http.BaseAddress ?? new Uri("http://localhost"), "mcp"); var transport = new HttpClientTransport( new HttpClientTransportOptions { Endpoint = endpoint }, http, ownsHttpClient: false); - return await McpClient.CreateAsync(transport); + return await McpClient.CreateAsync(transport, clientOptions); } } diff --git a/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolResults.cs b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolResults.cs index e276b95..49514c3 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolResults.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolResults.cs @@ -15,9 +15,11 @@ public static class McpToolResults PropertyNameCaseInsensitive = true }; + /// Joins text content blocks; experiments that need StructuredContent parse separately. public static string GetText(CallToolResult result) => string.Join("\n", result.Content.OfType().Select(b => b.Text)); + /// Truncates observation notes for JSON artifacts; does not change assertion text. public static string Truncate(string text, int maxLength = 500) => text.Length <= maxLength ? text : text[..maxLength]; diff --git a/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs index 416fe50..e5bb4f8 100644 --- a/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs +++ b/tests/Lab/IntegrationTests/Infrastructure/Mcp/McpToolSpans.cs @@ -8,6 +8,10 @@ namespace IntegrationTests.Infrastructure.Mcp; /// public static class McpToolSpans { + /// + /// First unseen mcp.tool span for this tool after . + /// prevents counting spans from earlier calls in the same capture. + /// public static CapturedActivity? TakeNew( IReadOnlyList all, string toolName, diff --git a/web/README.md b/web/README.md index 7278964..6f24f79 100644 --- a/web/README.md +++ b/web/README.md @@ -52,7 +52,7 @@ A chat-like panel where a user "asks" the app and the UI renders MCP tool calls - **UX:** user types a request → UI calls the MCP server → renders tool name, parameters, and result inline - **Fits the repo's MCP/MAF research thread:** the Cursor screenshot in the root README is a static proof; this is a live one -**Mapped to:** [BuildingBlocks.Mcp](https://www.nuget.org/packages/BuildingBlocks.Mcp) 1.0.0 — `[McpTool]` / `MapTool` → MCP tools at `/mcp`. +**Mapped to:** [BuildingBlocks.Mcp](https://www.nuget.org/packages/BuildingBlocks.Mcp) 1.1.0 — `[McpTool]` / `MapTool` → MCP tools at `/mcp`. ### 4. Feature-flag–gated UI