diff --git a/.agents/skills/expose-a-use-case/SKILL.md b/.agents/skills/expose-a-use-case/SKILL.md index 840b83b..454213e 100644 --- a/.agents/skills/expose-a-use-case/SKILL.md +++ b/.agents/skills/expose-a-use-case/SKILL.md @@ -29,7 +29,7 @@ The SDK finds every class in the assembly (`WithToolsFromAssembly`, `WithResourc 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. 6. Return a result model from `src/Presentation/Models/` (records with `[property: Description]`, because the output schema reads attributes), mapped in `ResultMapper`. Add new models to `AppJsonSerializerContext` (`json-serialization`). -7. Build a prompt's messages with `PromptContent`, and embed the movie or author it's about when you can find one. A prompt argument that names a movie or author gets suggestions from `Completions/CompletionHandler.cs`; add its name there. So does the id in a resource template, which suggests ids by their first characters or the title or name they belong to. +7. Build a prompt's messages with `PromptContent`, and embed the movie or author it's about when you can find one. A prompt argument that names a movie or author gets suggestions from `Completions/CompletionHandler.cs`; add its name there. So does a prompt's `movieId`, and the id in a resource template, which suggest ids by their first characters or the title or name they belong to. 8. Update the server instructions in `WebApplicationBuilderExtensions` if the model needs to know the new thing exists (`api-conventions`). 9. Test it twice: a unit test calling the static method with a faked `ISender` (`tests/Presentation.Tests.Unit/Tools/`), and an integration test through the MCP client (`presentation-integration-tests`). Add a tool to the list in `ToolListTests`. diff --git a/src/Presentation/Completions/CompletionHandler.cs b/src/Presentation/Completions/CompletionHandler.cs index d0cec4b..7dcc415 100644 --- a/src/Presentation/Completions/CompletionHandler.cs +++ b/src/Presentation/Completions/CompletionHandler.cs @@ -52,6 +52,9 @@ private static async Task CompletePromptArgument(ISender sender, return Result([.. page.Items.Select(m => m.Title)], page.TotalCount); } + case "movieId": + return await CompleteResourceId(sender, argument, cancellationToken); + case "authorName": { var page = await sender.Send(new AuthorQueries.GetAuthors.GetAuthorsQuery diff --git a/src/Presentation/Prompts/MoviePrompts.cs b/src/Presentation/Prompts/MoviePrompts.cs index 70505de..7c689ae 100644 --- a/src/Presentation/Prompts/MoviePrompts.cs +++ b/src/Presentation/Prompts/MoviePrompts.cs @@ -95,6 +95,29 @@ public static partial GetPromptResult ReviewMovie( }; } + /// Summarise what reviewers actually said about a movie: the themes they agree on, where they disagree, and whether their words match their stars. + /// The movie's id (a GUID). Start typing its title or id for suggestions. + [McpServerPrompt(Name = "summarise_movie_reviews", Title = "Summarise a movie's reviews", IconSource = ItemIcons.Review)] + public static async partial Task SummariseMovieReviews( + ISender sender, + Guid movieId, + CancellationToken cancellationToken) + { + var movie = (await sender.Send(new Queries.GetMovieById.GetMovieByIdQuery { Id = movieId }, cancellationToken)) + .ToResult(includeReviews: true); + + return new GetPromptResult + { + Description = $"Summarise the reviews of {movie.Title}", + Messages = + [ + PromptContent.Text($"Summarise what reviewers said about \"{movie.Title}\", using the movie below. Give the themes they " + + "agree on, where they disagree, and whether their words match their stars."), + PromptContent.Embed($"movies://{movie.Id}", McpSerializer.Serialize(movie)) + ] + }; + } + private static async Task FindMovie(ISender sender, string? title, CancellationToken cancellationToken) { var page = await sender.Send(new Queries.GetMovies.GetMoviesQuery diff --git a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs index 2d13dc3..06d9fb9 100644 --- a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs +++ b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs @@ -101,6 +101,19 @@ public async Task CompleteReviewId_WithEmptyValue_ShouldCap_AtTheProtocolLimit() result.Completion.HasMore.ShouldBe(true); } + [Fact] + public async Task CompleteMovieId_OnMovieReviewsPrompt_ShouldReturn_Ids() + { + // Arrange + var movie = (await this.CallAsync>("movies_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new PromptReference { Name = "summarise_movie_reviews" }, "movieId", movie.Title); + + // Assert + result.Completion.Values.ShouldContain(movie.Id.ToString()); + } + [Fact] public async Task CompleteMovieId_ShouldMatch_Title() { diff --git a/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs b/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs index e17383f..8fa91dd 100644 --- a/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs +++ b/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs @@ -16,7 +16,7 @@ public async Task ListPrompts_ShouldReturn_EveryPrompt() var prompts = await this.Client.ListPromptsAsync(); // Assert - prompts.Select(p => p.Name).ShouldBe(["summarise_movie_reception", "compare_movies", "review_movie", "summarise_author_reviews"], ignoreOrder: true); + prompts.Select(p => p.Name).ShouldBe(["summarise_movie_reception", "compare_movies", "review_movie", "summarise_author_reviews", "summarise_movie_reviews"], ignoreOrder: true); foreach (var prompt in prompts) { @@ -27,6 +27,33 @@ public async Task ListPrompts_ShouldReturn_EveryPrompt() } } + [Fact] + public async Task SummariseMovieReviews_ShouldEmbed_Movie() + { + // Arrange + var movie = await this.FirstMovieAsync(); + + // Act + var result = await this.Client.GetPromptAsync("summarise_movie_reviews", + new Dictionary { ["movieId"] = movie.Id }); + + // Assert + result.Messages.Count.ShouldBe(2); + result.Messages[0].Content.ShouldBeOfType().Text.ShouldContain(movie.Title); + ShouldEmbed(result.Messages[1], movie); + } + + [Fact] + public async Task SummariseMovieReviews_WithUnknownId_ShouldThrow() + { + // Act + var exception = await Should.ThrowAsync(() => this.Client.GetPromptAsync("summarise_movie_reviews", + new Dictionary { ["movieId"] = Guid.NewGuid() }).AsTask()); + + // Assert + exception.Message.ShouldNotBeNullOrWhiteSpace(); + } + [Fact] public async Task SummariseAuthorReviews_ShouldEmbed_Author() {