Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
9688b5c
Write nullable tool schema fields as anyOf so single-type clients acc…
stphnwlsh Nov 6, 2026
b61a4df
Link each movie, author and review a tool returns to its resource
stphnwlsh Nov 6, 2026
7e6b0c2
Say the schema filter is a workaround and when to remove it
stphnwlsh Nov 6, 2026
ffc7aff
Merge remote-tracking branch 'origin/main' into fix/portable-nullable…
stphnwlsh Nov 6, 2026
44eff08
Merge branch 'fix/portable-nullable-schemas' into feature/tool-resour…
stphnwlsh Nov 6, 2026
1684312
Merge branch 'main' into feature/tool-resource-links
stphnwlsh Nov 6, 2026
c9965c8
Merge remote-tracking branch 'origin/main' into feature/tool-resource…
stphnwlsh Nov 6, 2026
a2a9dc0
Merge remote-tracking branch 'origin/feature/tool-resource-links' int…
stphnwlsh Nov 6, 2026
6488248
Suggest ids for resource templates as people type
stphnwlsh Nov 6, 2026
2a87d57
Merge remote-tracking branch 'origin/main' into feature/completions-a…
stphnwlsh Nov 6, 2026
2ad50eb
Give every tool, prompt and resource an icon
stphnwlsh Nov 6, 2026
ca7f4d4
Add a prompt that summarises an author's reviews
stphnwlsh Nov 6, 2026
c67034a
Suggest ids from every page and tidy the completion checks
stphnwlsh Nov 6, 2026
fba08ab
Merge branch 'feature/completions-and-icons' into feature/author-prompt
stphnwlsh Nov 6, 2026
10de470
Merge remote-tracking branch 'origin/main' into feature/author-prompt
stphnwlsh Nov 6, 2026
38c095e
Add a prompt that summarises what a movie's reviews say
stphnwlsh Nov 6, 2026
547efd2
Merge remote-tracking branch 'origin/main' into feature/movie-reviews…
stphnwlsh Nov 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .agents/skills/expose-a-use-case/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: `<summary>` saying what it does, what it returns and which tool to call first; a `<param>` 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`.

Expand Down
3 changes: 3 additions & 0 deletions src/Presentation/Completions/CompletionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ private static async Task<CompleteResult> 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
Expand Down
23 changes: 23 additions & 0 deletions src/Presentation/Prompts/MoviePrompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,29 @@ public static partial GetPromptResult ReviewMovie(
};
}

/// <summary>Summarise what reviewers actually said about a movie: the themes they agree on, where they disagree, and whether their words match their stars.</summary>
/// <param name="movieId">The movie's id (a GUID). Start typing its title or id for suggestions.</param>
[McpServerPrompt(Name = "summarise_movie_reviews", Title = "Summarise a movie's reviews", IconSource = ItemIcons.Review)]
public static async partial Task<GetPromptResult> 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<MovieResult?> FindMovie(ISender sender, string? title, CancellationToken cancellationToken)
{
var page = await sender.Send(new Queries.GetMovies.GetMoviesQuery
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResultPage<MovieResult>>("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()
{
Expand Down
29 changes: 28 additions & 1 deletion tests/Presentation.Tests.Integration/Prompts/PromptTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand All @@ -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<string, object?> { ["movieId"] = movie.Id });

// Assert
result.Messages.Count.ShouldBe(2);
result.Messages[0].Content.ShouldBeOfType<TextContentBlock>().Text.ShouldContain(movie.Title);
ShouldEmbed(result.Messages[1], movie);
}

[Fact]
public async Task SummariseMovieReviews_WithUnknownId_ShouldThrow()
{
// Act
var exception = await Should.ThrowAsync<McpProtocolException>(() => this.Client.GetPromptAsync("summarise_movie_reviews",
new Dictionary<string, object?> { ["movieId"] = Guid.NewGuid() }).AsTask());

// Assert
exception.Message.ShouldNotBeNullOrWhiteSpace();
}

[Fact]
public async Task SummariseAuthorReviews_ShouldEmbed_Author()
{
Expand Down
Loading