diff --git a/.agents/skills/expose-a-use-case/SKILL.md b/.agents/skills/expose-a-use-case/SKILL.md index 6a0ff0f..4c62022 100644 --- a/.agents/skills/expose-a-use-case/SKILL.md +++ b/.agents/skills/expose-a-use-case/SKILL.md @@ -24,7 +24,7 @@ The SDK finds every class in the assembly (`WithToolsFromAssembly`, `WithResourc ## Steps 1. Add the use case first (`add-a-use-case`). -2. Pick the kind: a tool when the model should decide to call it, a resource when the client reads a known thing by id, a prompt when a person starts a task. +2. Pick the kind: a tool when the model should decide to call it, a resource when the client reads a known thing by id or a fixed list such as `movies://top-rated`, a prompt when a person starts a task. 3. Choose the name, title and annotations for a tool (`api-conventions`). 4. Write the method with `sender` and `cancellationToken` first, then required inputs, then optional ones with defaults. 5. Document it: `` saying what it does, what it returns and which tool to call first; a `` for every input except `sender` and the token, with allowed values and where to find ids. Put the XML comment above the attributes. diff --git a/src/Presentation/Extensions/WebApplicationBuilderExtensions.cs b/src/Presentation/Extensions/WebApplicationBuilderExtensions.cs index 65e167b..df1ce91 100644 --- a/src/Presentation/Extensions/WebApplicationBuilderExtensions.cs +++ b/src/Presentation/Extensions/WebApplicationBuilderExtensions.cs @@ -183,7 +183,8 @@ so pass nextOffset back as the offset to continue rather than raising the limit. reviews_write creates a review, or changes the stars of one when you pass its reviewId. A review's movie and author can't change. reviews_delete can't be undone, so confirm with the user before calling it. Each movie, author and review can also be read as a resource at movies://{id}, authors://{id} or - reviews://{id}, and server://version gives this server's version. + reviews://{id}. movies://top-rated lists the ten best rated movies, and server://version gives this + server's version. """; }) .WithHttpTransport(options => options.Stateless = true) diff --git a/src/Presentation/Resources/MovieResources.cs b/src/Presentation/Resources/MovieResources.cs index 1e78985..26d64fe 100644 --- a/src/Presentation/Resources/MovieResources.cs +++ b/src/Presentation/Resources/MovieResources.cs @@ -1,5 +1,7 @@ namespace CleanModelContextProtocol.Presentation.Resources; +using Application.Common.Paging; +using Application.Movies.Entities; using Mediator; using ModelContextProtocol.Server; using Presentation.Models; @@ -10,6 +12,42 @@ namespace CleanModelContextProtocol.Presentation.Resources; [McpServerResourceType] public static partial class MovieResources { + private const int TopRatedCount = 10; + + /// The ten best rated movies, highest average stars first, with each one's review count. + [McpServerResource(UriTemplate = "movies://top-rated", Name = "top_rated_movies", Title = "Top rated movies", MimeType = "application/json", IconSource = ItemIcons.Movie)] + public static async partial Task GetTopRatedMovies( + ISender sender, + CancellationToken cancellationToken) + { + var movies = new List(); + Page page; + + // The query sorts by title, so ranking needs every movie. + do + { + page = await sender.Send(new Queries.GetMovies.GetMoviesQuery + { + Offset = movies.Count, + Limit = Paging.MaxLimit + }, cancellationToken); + + movies.AddRange(page.Items); + } + while (movies.Count < page.TotalCount); + + var topRated = movies + .Where(m => m.ReviewCount > 0) + .OrderByDescending(m => m.AverageStars) + .ThenByDescending(m => m.ReviewCount) + .ThenBy(m => m.Title, StringComparer.Ordinal) + .Take(TopRatedCount) + .Select(m => m.ToResult()) + .ToList(); + + return McpSerializer.Serialize(new ResultPage(topRated, topRated.Count, null)); + } + /// A movie with its review count, average stars and every review, including each author's name. /// The movie's id (a GUID). [McpServerResource(UriTemplate = "movies://{movieId}", Name = "movie", Title = "Movie", MimeType = "application/json", IconSource = ItemIcons.Movie)] diff --git a/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs b/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs index 83c8afc..2973747 100644 --- a/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs +++ b/tests/Presentation.Tests.Integration/Resources/ResourceTests.cs @@ -22,15 +22,27 @@ public async Task ListResourceTemplates_ShouldReturn_EveryTemplate() } [Fact] - public async Task ListResources_ShouldReturn_ServerVersion() + public async Task ListResources_ShouldReturn_EveryResource() { // Act var resources = await this.Client.ListResourcesAsync(); // Assert - var resource = resources.ShouldHaveSingleItem(); - resource.Uri.ShouldBe("server://version"); - resource.MimeType.ShouldBe("application/json"); + resources.Select(r => r.Uri).ShouldBe(["server://version", "movies://top-rated"], ignoreOrder: true); + resources.ShouldAllBe(r => r.MimeType == "application/json"); + } + + [Fact] + public async Task ReadTopRated_ShouldReturn_BestMoviesFirst() + { + // Act + var page = await this.ReadAsync>("movies://top-rated"); + + // Assert + page.Items.Count.ShouldBe(10); + page.NextOffset.ShouldBeNull(); + page.Items.ShouldAllBe(m => m.ReviewCount > 0); + page.Items.Select(m => m.AverageStars).ShouldBeInOrder(SortDirection.Descending); } [Fact]