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
4 changes: 4 additions & 0 deletions .agents/skills/api-conventions/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ Anything else is unexpected. `ToolErrorFilter` logs it as `Error` and returns `E

Every tool that returns a movie, author or review also returns a `resource_link` block for it, as well as its structured content. The link's URI is the matching resource template, its name is the item's title or name, and its mime type is `application/json`. `ResourceLinkFilter` adds them, because a tool that returns a typed result has no way to add a content block itself. It takes the scheme from the start of the tool's name, so a new tool only has to follow the `movies_get` naming and return an `id`.

## Icons

Every tool, prompt and resource sets `IconSource` to a constant in `ItemIcons`, which points at a pinned brand release like the server's icon. Use the icon for what the item is about, not what it does. A new item needs one, or `EveryItem_ShouldHave_AnIcon` fails.

## Example

```csharp
Expand Down
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.
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.
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
117 changes: 113 additions & 4 deletions src/Presentation/Completions/CompletionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,28 +7,38 @@ namespace CleanModelContextProtocol.Presentation.Completions;
using ModelContextProtocol.Server;
using AuthorQueries = Application.Authors.Queries;
using MovieQueries = Application.Movies.Queries;
using ReviewQueries = Application.Reviews.Queries;

/// <summary>
/// Suggests movie titles and author names as the user types a prompt argument.
/// Suggests movie titles and author names as the user types a prompt argument, and ids as they type a resource
/// template's id. An id can be found by its first characters, or by the title or name of what it belongs to.
/// </summary>
public static class CompletionHandler
{
private const int MaxQueryLength = 100;

// The most values the protocol allows in one completion.
private const int MaxValues = 100;

public static async ValueTask<CompleteResult> Handle(RequestContext<CompleteRequestParams> context, CancellationToken cancellationToken)
{
return await Complete(context.Services!.GetRequiredService<ISender>(), context.Params, cancellationToken);
}

public static async Task<CompleteResult> Complete(ISender sender, CompleteRequestParams? request, CancellationToken cancellationToken)
{
var argument = request?.Argument;

if (request?.Ref is not PromptReference || argument is null || argument.Value?.Length > MaxQueryLength)
if (request?.Argument is not { } argument || argument.Value.Length > MaxQueryLength)
{
return Empty();
}

return request.Ref is PromptReference
? await CompletePromptArgument(sender, argument, cancellationToken)
: await CompleteResourceId(sender, argument, cancellationToken);
}

private static async Task<CompleteResult> CompletePromptArgument(ISender sender, Argument argument, CancellationToken cancellationToken)
{
switch (argument.Name)
{
case "movieTitle" or "firstMovieTitle" or "secondMovieTitle":
Expand Down Expand Up @@ -58,6 +68,105 @@ public static async Task<CompleteResult> Complete(ISender sender, CompleteReques
}
}

private static async Task<CompleteResult> CompleteResourceId(ISender sender, Argument argument, CancellationToken cancellationToken)
{
switch (argument.Name)
{
case "movieId":
{
var page = await sender.Send(new MovieQueries.GetMovies.GetMoviesQuery
{
Query = argument.Value,
Limit = Paging.MaxLimit
}, cancellationToken);

if (page.Items.Count > 0)
{
return Result([.. page.Items.Select(m => m.Id.ToString())], page.TotalCount);
}

var movies = await GetAll(offset => sender.Send(new MovieQueries.GetMovies.GetMoviesQuery
{
Offset = offset,
Limit = Paging.MaxLimit
}, cancellationToken));

return IdsStartingWith(movies.Select(m => m.Id), argument.Value);
}

case "authorId":
{
var page = await sender.Send(new AuthorQueries.GetAuthors.GetAuthorsQuery
{
Query = argument.Value,
Limit = Paging.MaxLimit
}, cancellationToken);

if (page.Items.Count > 0)
{
return Result([.. page.Items.Select(a => a.Id.ToString())], page.TotalCount);
}

var authors = await GetAll(offset => sender.Send(new AuthorQueries.GetAuthors.GetAuthorsQuery
{
Offset = offset,
Limit = Paging.MaxLimit
}, cancellationToken));

return IdsStartingWith(authors.Select(a => a.Id), argument.Value);
}

case "reviewId":
{
var reviews = await GetAll(offset => sender.Send(new ReviewQueries.GetReviews.GetReviewsQuery
{
Offset = offset,
Limit = Paging.MaxLimit
}, cancellationToken));
var matches = reviews
.Where(r => r.Id.ToString().StartsWith(argument.Value, StringComparison.OrdinalIgnoreCase)
|| (r.ReviewedMovie?.Title.Contains(argument.Value, StringComparison.OrdinalIgnoreCase) ?? false))
.Select(r => r.Id.ToString())
.ToList();

return Capped(matches);
}

default:
return Empty();
}
}

private static CompleteResult IdsStartingWith(IEnumerable<Guid> ids, string value)
{
var matches = ids
.Select(i => i.ToString())
.Where(i => i.StartsWith(value, StringComparison.OrdinalIgnoreCase))
.ToList();

return Capped(matches);
}

private static async Task<List<T>> GetAll<T>(Func<int, ValueTask<Page<T>>> getPage)
{
var items = new List<T>();
Page<T> page;

do
{
page = await getPage(items.Count);
items.AddRange(page.Items);
}
while (items.Count < page.TotalCount);

return items;
}

private static CompleteResult Capped(List<string> matches)
{
return Result([.. matches.Take(MaxValues)], matches.Count);
}

private static CompleteResult Result(List<string> values, int total)
{
return new CompleteResult
Expand Down
7 changes: 4 additions & 3 deletions src/Presentation/Prompts/MoviePrompts.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,15 @@ namespace CleanModelContextProtocol.Presentation.Prompts;
using ModelContextProtocol.Server;
using Presentation.Models;
using Presentation.Serialization;
using Presentation.Tools;
using Queries = Application.Movies.Queries;

[McpServerPromptType]
public static partial class MoviePrompts
{
/// <summary>Summarise how reviewers have received a movie: its average stars, how the ratings spread, and who liked it most and least.</summary>
/// <param name="movieTitle">The movie's title, or part of it.</param>
[McpServerPrompt(Name = "summarise_movie_reception", Title = "Summarise a movie's reception")]
[McpServerPrompt(Name = "summarise_movie_reception", Title = "Summarise a movie's reception", IconSource = ItemIcons.Movie)]
public static async partial Task<GetPromptResult> SummariseMovieReception(
ISender sender,
string movieTitle,
Expand Down Expand Up @@ -44,7 +45,7 @@ public static async partial Task<GetPromptResult> SummariseMovieReception(
/// <summary>Compare how reviewers rated two movies, and say which one they preferred and by how much.</summary>
/// <param name="firstMovieTitle">The first movie's title, or part of it.</param>
/// <param name="secondMovieTitle">The second movie's title, or part of it.</param>
[McpServerPrompt(Name = "compare_movies", Title = "Compare two movies")]
[McpServerPrompt(Name = "compare_movies", Title = "Compare two movies", IconSource = ItemIcons.Movie)]
public static async partial Task<GetPromptResult> CompareMovies(
ISender sender,
string firstMovieTitle,
Expand Down Expand Up @@ -76,7 +77,7 @@ public static async partial Task<GetPromptResult> CompareMovies(
/// <summary>Write a review of a movie as one of the authors, checking the movie and author before saving it.</summary>
/// <param name="movieTitle">The movie's title, or part of it.</param>
/// <param name="authorName">The name of the author writing the review.</param>
[McpServerPrompt(Name = "review_movie", Title = "Review a movie")]
[McpServerPrompt(Name = "review_movie", Title = "Review a movie", IconSource = ItemIcons.Review)]
public static partial GetPromptResult ReviewMovie(
string movieTitle,
string authorName)
Expand Down
3 changes: 2 additions & 1 deletion src/Presentation/Resources/AuthorResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ namespace CleanModelContextProtocol.Presentation.Resources;
using ModelContextProtocol.Server;
using Presentation.Models;
using Presentation.Serialization;
using Presentation.Tools;
using Queries = Application.Authors.Queries;

[McpServerResourceType]
public static partial class AuthorResources
{
/// <summary>An author with their review count, average stars and every review they have written, including each movie's title.</summary>
/// <param name="authorId">The author's id (a GUID).</param>
[McpServerResource(UriTemplate = "authors://{authorId}", Name = "author", Title = "Author", MimeType = "application/json")]
[McpServerResource(UriTemplate = "authors://{authorId}", Name = "author", Title = "Author", MimeType = "application/json", IconSource = ItemIcons.Author)]
public static async partial Task<string> GetAuthor(
ISender sender,
Guid authorId,
Expand Down
3 changes: 2 additions & 1 deletion src/Presentation/Resources/MovieResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ namespace CleanModelContextProtocol.Presentation.Resources;
using ModelContextProtocol.Server;
using Presentation.Models;
using Presentation.Serialization;
using Presentation.Tools;
using Queries = Application.Movies.Queries;

[McpServerResourceType]
public static partial class MovieResources
{
/// <summary>A movie with its review count, average stars and every review, including each author's name.</summary>
/// <param name="movieId">The movie's id (a GUID).</param>
[McpServerResource(UriTemplate = "movies://{movieId}", Name = "movie", Title = "Movie", MimeType = "application/json")]
[McpServerResource(UriTemplate = "movies://{movieId}", Name = "movie", Title = "Movie", MimeType = "application/json", IconSource = ItemIcons.Movie)]
public static async partial Task<string> GetMovie(
ISender sender,
Guid movieId,
Expand Down
3 changes: 2 additions & 1 deletion src/Presentation/Resources/ReviewResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ namespace CleanModelContextProtocol.Presentation.Resources;
using ModelContextProtocol.Server;
using Presentation.Models;
using Presentation.Serialization;
using Presentation.Tools;
using Queries = Application.Reviews.Queries;

[McpServerResourceType]
public static partial class ReviewResources
{
/// <summary>A review with its star rating, the movie's title and the author's name.</summary>
/// <param name="reviewId">The review's id (a GUID).</param>
[McpServerResource(UriTemplate = "reviews://{reviewId}", Name = "review", Title = "Review", MimeType = "application/json")]
[McpServerResource(UriTemplate = "reviews://{reviewId}", Name = "review", Title = "Review", MimeType = "application/json", IconSource = ItemIcons.Review)]
public static async partial Task<string> GetReview(
ISender sender,
Guid reviewId,
Expand Down
3 changes: 2 additions & 1 deletion src/Presentation/Resources/VersionResources.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ namespace CleanModelContextProtocol.Presentation.Resources;
using Mediator;
using ModelContextProtocol.Server;
using Presentation.Serialization;
using Presentation.Tools;
using Queries = Application.Versions.Queries;

[McpServerResourceType]
public static partial class VersionResources
{
/// <summary>The version of this server: the file version and the informational version, which includes the commit it was built from.</summary>
[McpServerResource(UriTemplate = "server://version", Name = "server_version", Title = "Server version", MimeType = "application/json")]
[McpServerResource(UriTemplate = "server://version", Name = "server_version", Title = "Server version", MimeType = "application/json", IconSource = ItemIcons.Server)]
public static async partial Task<string> GetVersion(ISender sender, CancellationToken cancellationToken)
{
return McpSerializer.Serialize(await sender.Send(new Queries.GetVersion.GetVersionQuery(), cancellationToken));
Expand Down
2 changes: 1 addition & 1 deletion src/Presentation/Tools/AuthorTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static partial class AuthorTools
/// <param name="offset">The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page.</param>
/// <param name="limit">The most results to return, from 1 to 50.</param>
/// <param name="includeReviews">Also return every review each author has written. Leave false unless you need the individual reviews.</param>
[McpServerTool(Name = "authors_search", Title = "Search authors", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "authors_search", Title = "Search authors", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Author)]
public static async partial Task<ResultPage<AuthorResult>> SearchAuthors(
ISender sender,
CancellationToken cancellationToken,
Expand Down
15 changes: 15 additions & 0 deletions src/Presentation/Tools/ItemIcons.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace CleanModelContextProtocol.Presentation.Tools;

/// <summary>
/// The icons on tools, prompts and resources, from a pinned brand release like the server's own icon. They point at
/// the brand repository, not this one, because dotnet new renames the template's name wherever it appears.
/// </summary>
public static class ItemIcons
{
public const string Movie = Source + "movie-64.png";
public const string Author = Source + "author-64.png";
public const string Review = Source + "review-64.png";
public const string Server = Source + "server-64.png";

private const string Source = "https://raw.githubusercontent.com/LeanMeanCleanApps/brand/2026.11.06/assets/icons/mcp/png/";
}
4 changes: 2 additions & 2 deletions src/Presentation/Tools/MovieTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static partial class MovieTools
/// <param name="offset">The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page.</param>
/// <param name="limit">The most results to return, from 1 to 50.</param>
/// <param name="includeReviews">Also return every review of each movie. Leave false unless you need the individual reviews.</param>
[McpServerTool(Name = "movies_search", Title = "Search movies", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "movies_search", Title = "Search movies", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Movie)]
public static async partial Task<ResultPage<MovieResult>> SearchMovies(
ISender sender,
CancellationToken cancellationToken,
Expand Down Expand Up @@ -50,7 +50,7 @@ public static async partial Task<ResultPage<MovieResult>> SearchMovies(

/// <summary>Get one movie with its review count, average stars and every review, including each author's name.</summary>
/// <param name="movieId">The movie's id (a GUID). Use movies_search to find it.</param>
[McpServerTool(Name = "movies_get", Title = "Get movie", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "movies_get", Title = "Get movie", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Movie)]
public static async partial Task<MovieResult> GetMovie(
ISender sender,
Guid movieId,
Expand Down
6 changes: 3 additions & 3 deletions src/Presentation/Tools/ReviewTools.cs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public static partial class ReviewTools
/// <param name="reviewId">Get this one review instead of searching. The other filters are ignored.</param>
/// <param name="offset">The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page.</param>
/// <param name="limit">The most results to return, from 1 to 50.</param>
[McpServerTool(Name = "reviews_list", Title = "List reviews", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "reviews_list", Title = "List reviews", ReadOnly = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)]
public static async partial Task<ResultPage<ReviewResult>> ListReviews(
ISender sender,
CancellationToken cancellationToken,
Expand Down Expand Up @@ -58,7 +58,7 @@ public static async partial Task<ResultPage<ReviewResult>> ListReviews(
/// <param name="movieId">The id of the movie being reviewed, needed to create a review. Use movies_search to find it.</param>
/// <param name="authorId">The id of the author writing the review, needed to create a review. Use authors_search to find it.</param>
/// <param name="reviewId">The id of an existing review to change the stars of. Leave empty to create a new review.</param>
[McpServerTool(Name = "reviews_write", Title = "Write review", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "reviews_write", Title = "Write review", ReadOnly = false, Destructive = true, Idempotent = false, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)]
public static async partial Task<ReviewResult> WriteReview(
ISender sender,
int stars,
Expand Down Expand Up @@ -90,7 +90,7 @@ public static async partial Task<ReviewResult> WriteReview(

/// <summary>Permanently delete a review. This can't be undone, so confirm with the user first.</summary>
/// <param name="reviewId">The id of the review to delete. Use reviews_list to find it.</param>
[McpServerTool(Name = "reviews_delete", Title = "Delete review", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true)]
[McpServerTool(Name = "reviews_delete", Title = "Delete review", ReadOnly = false, Destructive = true, Idempotent = true, OpenWorld = false, UseStructuredContent = true, IconSource = ItemIcons.Review)]
public static async partial Task<DeletedReview> DeleteReview(
ISender sender,
Guid reviewId,
Expand Down
Loading
Loading