From fd045e28025c81da83ac403f4ad5b8a81b4004a6 Mon Sep 17 00:00:00 2001 From: Stephen Walsh <868017+stphnwlsh@users.noreply.github.com> Date: Fri, 6 Nov 2026 15:20:53 -0600 Subject: [PATCH] Add a prompt that summarises an author's reviews # Conflicts: # tests/Presentation.Tests.Integration/Completions/CompletionTests.cs --- .agents/skills/expose-a-use-case/SKILL.md | 2 +- src/Presentation/Prompts/AuthorPrompts.cs | 67 +++++++++++++++++++ src/Presentation/Prompts/MoviePrompts.cs | 36 ++-------- src/Presentation/Prompts/PromptContent.cs | 35 ++++++++++ .../Completions/CompletionTests.cs | 13 ++++ .../Prompts/PromptTests.cs | 45 ++++++++++++- 6 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 src/Presentation/Prompts/AuthorPrompts.cs create mode 100644 src/Presentation/Prompts/PromptContent.cs diff --git a/.agents/skills/expose-a-use-case/SKILL.md b/.agents/skills/expose-a-use-case/SKILL.md index 4c62022..840b83b 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. 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 the id in a resource template, which suggests 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/Prompts/AuthorPrompts.cs b/src/Presentation/Prompts/AuthorPrompts.cs new file mode 100644 index 0000000..d8043c4 --- /dev/null +++ b/src/Presentation/Prompts/AuthorPrompts.cs @@ -0,0 +1,67 @@ +namespace CleanModelContextProtocol.Presentation.Prompts; + +using Mediator; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using Presentation.Models; +using Presentation.Serialization; +using Presentation.Tools; +using Queries = Application.Authors.Queries; + +[McpServerPromptType] +public static partial class AuthorPrompts +{ + /// Summarise an author's reviewing habits: how many reviews they've written, how generous they are, and the movies they rated highest and lowest. + /// The author's name, or part of it. + [McpServerPrompt(Name = "summarise_author_reviews", Title = "Summarise an author's reviews", IconSource = ItemIcons.Author)] + public static async partial Task SummariseAuthorReviews( + ISender sender, + string authorName, + CancellationToken cancellationToken) + { + var author = await FindAuthor(sender, authorName, cancellationToken); + + var messages = new List + { + PromptContent.Text(author is null + ? $"Summarise the reviewing habits of the author \"{authorName}\". Find them with authors_search, get their reviews " + + "with reviews_list, then say how many reviews they've written, how generous they are, and which movies they rated " + + "highest and lowest." + : $"Summarise the reviewing habits of {author.Name}, using the author below. Say how many reviews they've written, " + + "how generous they are, and which movies they rated highest and lowest.") + }; + + if (author is not null) + { + messages.Add(PromptContent.Embed($"authors://{author.Id}", McpSerializer.Serialize(author))); + } + + return new GetPromptResult + { + Description = $"Summarise the reviews of {author?.Name ?? authorName}", + Messages = messages + }; + } + + private static async Task FindAuthor(ISender sender, string? name, CancellationToken cancellationToken) + { + var page = await sender.Send(new Queries.GetAuthors.GetAuthorsQuery + { + Query = name, + Limit = 5 + }, cancellationToken); + + var author = page.Items.FirstOrDefault(a => string.Equals(a.ToResult().Name, name?.Trim(), StringComparison.OrdinalIgnoreCase)) + ?? (page.TotalCount == 1 ? page.Items[0] : null); + + if (author is null) + { + return null; + } + + // The search only has the review count and average, so get the author again for their reviews. + var withReviews = await sender.Send(new Queries.GetAuthorById.GetAuthorByIdQuery { Id = author.Id }, cancellationToken); + + return withReviews.ToResult(includeReviews: true); + } +} diff --git a/src/Presentation/Prompts/MoviePrompts.cs b/src/Presentation/Prompts/MoviePrompts.cs index 64f7d1f..70505de 100644 --- a/src/Presentation/Prompts/MoviePrompts.cs +++ b/src/Presentation/Prompts/MoviePrompts.cs @@ -23,7 +23,7 @@ public static async partial Task SummariseMovieReception( var messages = new List { - Text(movie is null + PromptContent.Text(movie is null ? $"Summarise how reviewers have received the movie \"{movieTitle}\". Find it with movies_search, get its reviews " + "with movies_get, then give its average stars, how the ratings spread from 1 to 5, and which authors rated it highest and lowest." : $"Summarise how reviewers have received \"{movie.Title}\", using the movie below. Give its average stars, " + @@ -32,7 +32,7 @@ public static async partial Task SummariseMovieReception( if (movie is not null) { - messages.Add(Embed(movie)); + messages.Add(PromptContent.Embed($"movies://{movie.Id}", McpSerializer.Serialize(movie))); } return new GetPromptResult @@ -57,14 +57,14 @@ public static async partial Task CompareMovies( var messages = new List { - Text($"Compare how reviewers rated \"{first?.Title ?? firstMovieTitle}\" and \"{second?.Title ?? secondMovieTitle}\". " + + PromptContent.Text($"Compare how reviewers rated \"{first?.Title ?? firstMovieTitle}\" and \"{second?.Title ?? secondMovieTitle}\". " + "Give each movie's average stars and number of reviews, note any authors who reviewed both, and say which movie " + "reviewers preferred. Find any movie that isn't attached below with movies_search, then get it with movies_get.") }; foreach (var movie in new[] { first, second }.OfType()) { - messages.Add(Embed(movie)); + messages.Add(PromptContent.Embed($"movies://{movie.Id}", McpSerializer.Serialize(movie))); } return new GetPromptResult @@ -87,7 +87,7 @@ public static partial GetPromptResult ReviewMovie( Description = $"Review {movieTitle} as {authorName}", Messages = [ - Text($"I want to review the movie \"{movieTitle}\" as the author \"{authorName}\". Find the movie with movies_search " + + PromptContent.Text($"I want to review the movie \"{movieTitle}\" as the author \"{authorName}\". Find the movie with movies_search " + "and the author with authors_search. If either search doesn't return exactly one clear match, show me the closest " + "results and ask which one I meant. Ask me for a star rating from 1 to 5 and what I thought of it, then save the review with reviews_write " + "and show me the saved review.") @@ -116,30 +116,4 @@ public static partial GetPromptResult ReviewMovie( return withReviews.ToResult(includeReviews: true); } - - private static PromptMessage Text(string text) - { - return new PromptMessage - { - Role = Role.User, - Content = new TextContentBlock { Text = text } - }; - } - - private static PromptMessage Embed(MovieResult movie) - { - return new PromptMessage - { - Role = Role.User, - Content = new EmbeddedResourceBlock - { - Resource = new TextResourceContents - { - Uri = $"movies://{movie.Id}", - MimeType = "application/json", - Text = McpSerializer.Serialize(movie) - } - } - }; - } } diff --git a/src/Presentation/Prompts/PromptContent.cs b/src/Presentation/Prompts/PromptContent.cs new file mode 100644 index 0000000..ece9037 --- /dev/null +++ b/src/Presentation/Prompts/PromptContent.cs @@ -0,0 +1,35 @@ +namespace CleanModelContextProtocol.Presentation.Prompts; + +using ModelContextProtocol.Protocol; + +/// +/// The messages a prompt is made of: an instruction, and the movie or author it's about. +/// +internal static class PromptContent +{ + public static PromptMessage Text(string text) + { + return new PromptMessage + { + Role = Role.User, + Content = new TextContentBlock { Text = text } + }; + } + + public static PromptMessage Embed(string uri, string json) + { + return new PromptMessage + { + Role = Role.User, + Content = new EmbeddedResourceBlock + { + Resource = new TextResourceContents + { + Uri = uri, + MimeType = "application/json", + Text = json + } + } + }; + } +} diff --git a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs index 4105250..2d13dc3 100644 --- a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs +++ b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs @@ -62,6 +62,19 @@ public async Task Complete_WithUnsupportedArgument_ShouldReturn_Nothing(string p result.Completion.HasMore.ShouldBe(false); } + [Fact] + public async Task CompleteAuthorName_OnAuthorPrompt_ShouldReturn_MatchingNames() + { + // Arrange + var author = (await this.CallAsync>("authors_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new PromptReference { Name = "summarise_author_reviews" }, "authorName", author.Name[..3]); + + // Assert + result.Completion.Values.ShouldContain(author.Name); + } + [Fact] public async Task CompleteReviewId_ShouldFind_ReviewBeyondTheFirstPage() { diff --git a/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs b/tests/Presentation.Tests.Integration/Prompts/PromptTests.cs index 5886ce2..e17383f 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"], ignoreOrder: true); + prompts.Select(p => p.Name).ShouldBe(["summarise_movie_reception", "compare_movies", "review_movie", "summarise_author_reviews"], ignoreOrder: true); foreach (var prompt in prompts) { @@ -27,6 +27,49 @@ public async Task ListPrompts_ShouldReturn_EveryPrompt() } } + [Fact] + public async Task SummariseAuthorReviews_ShouldEmbed_Author() + { + // Arrange + var author = (await this.CallAsync>("authors_search")).Items[0]; + + // Act + var result = await this.Client.GetPromptAsync("summarise_author_reviews", + new Dictionary { ["authorName"] = author.Name.ToUpperInvariant() }); + + // Assert + result.Messages.Count.ShouldBe(2); + result.Messages[0].Content.ShouldBeOfType().Text.ShouldContain(author.Name); + + var embedded = result.Messages[1].Content.ShouldBeOfType().Resource.ShouldBeOfType(); + + embedded.Uri.ShouldBe($"authors://{author.Id}"); + embedded.Text.Deserialize().Reviews.ShouldNotBeNull(); + } + + [Fact] + public async Task SummariseAuthorReviews_WithUnknownName_ShouldAsk_ToSearch() + { + // Act + var result = await this.Client.GetPromptAsync("summarise_author_reviews", + new Dictionary { ["authorName"] = "No Such Author" }); + + // Assert + var message = result.Messages.ShouldHaveSingleItem(); + message.Content.ShouldBeOfType().Text.ShouldContain("Find them with authors_search"); + } + + [Fact] + public async Task SummariseAuthorReviews_WithLongName_ShouldThrow_InvalidParams() + { + // Act + var exception = await Should.ThrowAsync(() => this.Client.GetPromptAsync("summarise_author_reviews", + new Dictionary { ["authorName"] = new string('a', 201) }).AsTask()); + + // Assert + exception.ErrorCode.ShouldBe(McpErrorCode.InvalidParams); + } + [Fact] public async Task SummariseMovieReception_ShouldEmbed_Movie() {