Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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. 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`.

Expand Down
67 changes: 67 additions & 0 deletions src/Presentation/Prompts/AuthorPrompts.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>Summarise an author's reviewing habits: how many reviews they've written, how generous they are, and the movies they rated highest and lowest.</summary>
/// <param name="authorName">The author's name, or part of it.</param>
[McpServerPrompt(Name = "summarise_author_reviews", Title = "Summarise an author's reviews", IconSource = ItemIcons.Author)]
public static async partial Task<GetPromptResult> SummariseAuthorReviews(
ISender sender,
string authorName,
CancellationToken cancellationToken)
{
var author = await FindAuthor(sender, authorName, cancellationToken);

var messages = new List<PromptMessage>
{
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<AuthorResult?> 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);
}
}
36 changes: 5 additions & 31 deletions src/Presentation/Prompts/MoviePrompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ public static async partial Task<GetPromptResult> SummariseMovieReception(

var messages = new List<PromptMessage>
{
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, " +
Expand All @@ -32,7 +32,7 @@ public static async partial Task<GetPromptResult> SummariseMovieReception(

if (movie is not null)
{
messages.Add(Embed(movie));
messages.Add(PromptContent.Embed($"movies://{movie.Id}", McpSerializer.Serialize(movie)));
}

return new GetPromptResult
Expand All @@ -57,14 +57,14 @@ public static async partial Task<GetPromptResult> CompareMovies(

var messages = new List<PromptMessage>
{
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<MovieResult>())
{
messages.Add(Embed(movie));
messages.Add(PromptContent.Embed($"movies://{movie.Id}", McpSerializer.Serialize(movie)));
}

return new GetPromptResult
Expand All @@ -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.")
Expand Down Expand Up @@ -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)
}
}
};
}
}
35 changes: 35 additions & 0 deletions src/Presentation/Prompts/PromptContent.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
namespace CleanModelContextProtocol.Presentation.Prompts;

using ModelContextProtocol.Protocol;

/// <summary>
/// The messages a prompt is made of: an instruction, and the movie or author it's about.
/// </summary>
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
}
}
};
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResultPage<AuthorResult>>("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()
{
Expand Down
45 changes: 44 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"], 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)
{
Expand All @@ -27,6 +27,49 @@ public async Task ListPrompts_ShouldReturn_EveryPrompt()
}
}

[Fact]
public async Task SummariseAuthorReviews_ShouldEmbed_Author()
{
// Arrange
var author = (await this.CallAsync<ResultPage<AuthorResult>>("authors_search")).Items[0];

// Act
var result = await this.Client.GetPromptAsync("summarise_author_reviews",
new Dictionary<string, object?> { ["authorName"] = author.Name.ToUpperInvariant() });

// Assert
result.Messages.Count.ShouldBe(2);
result.Messages[0].Content.ShouldBeOfType<TextContentBlock>().Text.ShouldContain(author.Name);

var embedded = result.Messages[1].Content.ShouldBeOfType<EmbeddedResourceBlock>().Resource.ShouldBeOfType<TextResourceContents>();

embedded.Uri.ShouldBe($"authors://{author.Id}");
embedded.Text.Deserialize<AuthorResult>().Reviews.ShouldNotBeNull();
}

[Fact]
public async Task SummariseAuthorReviews_WithUnknownName_ShouldAsk_ToSearch()
{
// Act
var result = await this.Client.GetPromptAsync("summarise_author_reviews",
new Dictionary<string, object?> { ["authorName"] = "No Such Author" });

// Assert
var message = result.Messages.ShouldHaveSingleItem();
message.Content.ShouldBeOfType<TextContentBlock>().Text.ShouldContain("Find them with authors_search");
}

[Fact]
public async Task SummariseAuthorReviews_WithLongName_ShouldThrow_InvalidParams()
{
// Act
var exception = await Should.ThrowAsync<McpProtocolException>(() => this.Client.GetPromptAsync("summarise_author_reviews",
new Dictionary<string, object?> { ["authorName"] = new string('a', 201) }).AsTask());

// Assert
exception.ErrorCode.ShouldBe(McpErrorCode.InvalidParams);
}

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