diff --git a/.agents/skills/api-conventions/SKILL.md b/.agents/skills/api-conventions/SKILL.md index 7c427db..e876f5b 100644 --- a/.agents/skills/api-conventions/SKILL.md +++ b/.agents/skills/api-conventions/SKILL.md @@ -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 diff --git a/.agents/skills/expose-a-use-case/SKILL.md b/.agents/skills/expose-a-use-case/SKILL.md index 0ce704a..6a0ff0f 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. +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`. diff --git a/src/Presentation/Completions/CompletionHandler.cs b/src/Presentation/Completions/CompletionHandler.cs index 0bd0ec0..d0cec4b 100644 --- a/src/Presentation/Completions/CompletionHandler.cs +++ b/src/Presentation/Completions/CompletionHandler.cs @@ -7,14 +7,19 @@ namespace CleanModelContextProtocol.Presentation.Completions; using ModelContextProtocol.Server; using AuthorQueries = Application.Authors.Queries; using MovieQueries = Application.Movies.Queries; +using ReviewQueries = Application.Reviews.Queries; /// -/// 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. /// 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 Handle(RequestContext context, CancellationToken cancellationToken) { return await Complete(context.Services!.GetRequiredService(), context.Params, cancellationToken); @@ -22,13 +27,18 @@ public static async ValueTask Handle(RequestContext 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 CompletePromptArgument(ISender sender, Argument argument, CancellationToken cancellationToken) + { switch (argument.Name) { case "movieTitle" or "firstMovieTitle" or "secondMovieTitle": @@ -58,6 +68,105 @@ public static async Task Complete(ISender sender, CompleteReques } } + private static async Task 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 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> GetAll(Func>> getPage) + { + var items = new List(); + Page page; + + do + { + page = await getPage(items.Count); + items.AddRange(page.Items); + } + while (items.Count < page.TotalCount); + + return items; + } + + private static CompleteResult Capped(List matches) + { + return Result([.. matches.Take(MaxValues)], matches.Count); + } + private static CompleteResult Result(List values, int total) { return new CompleteResult diff --git a/src/Presentation/Prompts/MoviePrompts.cs b/src/Presentation/Prompts/MoviePrompts.cs index 696f8cc..760f1a3 100644 --- a/src/Presentation/Prompts/MoviePrompts.cs +++ b/src/Presentation/Prompts/MoviePrompts.cs @@ -5,6 +5,7 @@ namespace CleanModelContextProtocol.Presentation.Prompts; using ModelContextProtocol.Server; using Presentation.Models; using Presentation.Serialization; +using Presentation.Tools; using Queries = Application.Movies.Queries; [McpServerPromptType] @@ -12,7 +13,7 @@ public static partial class MoviePrompts { /// Summarise how reviewers have received a movie: its average stars, how the ratings spread, and who liked it most and least. /// The movie's title, or part of it. - [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 SummariseMovieReception( ISender sender, string movieTitle, @@ -44,7 +45,7 @@ public static async partial Task SummariseMovieReception( /// Compare how reviewers rated two movies, and say which one they preferred and by how much. /// The first movie's title, or part of it. /// The second movie's title, or part of it. - [McpServerPrompt(Name = "compare_movies", Title = "Compare two movies")] + [McpServerPrompt(Name = "compare_movies", Title = "Compare two movies", IconSource = ItemIcons.Movie)] public static async partial Task CompareMovies( ISender sender, string firstMovieTitle, @@ -76,7 +77,7 @@ public static async partial Task CompareMovies( /// Write a review of a movie as one of the authors, checking the movie and author before saving it. /// The movie's title, or part of it. /// The name of the author writing the review. - [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) diff --git a/src/Presentation/Resources/AuthorResources.cs b/src/Presentation/Resources/AuthorResources.cs index 726102b..cb23e09 100644 --- a/src/Presentation/Resources/AuthorResources.cs +++ b/src/Presentation/Resources/AuthorResources.cs @@ -4,6 +4,7 @@ namespace CleanModelContextProtocol.Presentation.Resources; using ModelContextProtocol.Server; using Presentation.Models; using Presentation.Serialization; +using Presentation.Tools; using Queries = Application.Authors.Queries; [McpServerResourceType] @@ -11,7 +12,7 @@ public static partial class AuthorResources { /// An author with their review count, average stars and every review they have written, including each movie's title. /// The author's id (a GUID). - [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 GetAuthor( ISender sender, Guid authorId, diff --git a/src/Presentation/Resources/MovieResources.cs b/src/Presentation/Resources/MovieResources.cs index 35bbf3a..1e78985 100644 --- a/src/Presentation/Resources/MovieResources.cs +++ b/src/Presentation/Resources/MovieResources.cs @@ -4,6 +4,7 @@ namespace CleanModelContextProtocol.Presentation.Resources; using ModelContextProtocol.Server; using Presentation.Models; using Presentation.Serialization; +using Presentation.Tools; using Queries = Application.Movies.Queries; [McpServerResourceType] @@ -11,7 +12,7 @@ public static partial class MovieResources { /// A movie with its review count, average stars and every review, including each author's name. /// The movie's id (a GUID). - [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 GetMovie( ISender sender, Guid movieId, diff --git a/src/Presentation/Resources/ReviewResources.cs b/src/Presentation/Resources/ReviewResources.cs index a876371..e264e32 100644 --- a/src/Presentation/Resources/ReviewResources.cs +++ b/src/Presentation/Resources/ReviewResources.cs @@ -4,6 +4,7 @@ namespace CleanModelContextProtocol.Presentation.Resources; using ModelContextProtocol.Server; using Presentation.Models; using Presentation.Serialization; +using Presentation.Tools; using Queries = Application.Reviews.Queries; [McpServerResourceType] @@ -11,7 +12,7 @@ public static partial class ReviewResources { /// A review with its star rating, the movie's title and the author's name. /// The review's id (a GUID). - [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 GetReview( ISender sender, Guid reviewId, diff --git a/src/Presentation/Resources/VersionResources.cs b/src/Presentation/Resources/VersionResources.cs index 415adb1..bd9cc5c 100644 --- a/src/Presentation/Resources/VersionResources.cs +++ b/src/Presentation/Resources/VersionResources.cs @@ -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 { /// The version of this server: the file version and the informational version, which includes the commit it was built from. - [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 GetVersion(ISender sender, CancellationToken cancellationToken) { return McpSerializer.Serialize(await sender.Send(new Queries.GetVersion.GetVersionQuery(), cancellationToken)); diff --git a/src/Presentation/Tools/AuthorTools.cs b/src/Presentation/Tools/AuthorTools.cs index 28431fa..93f3add 100644 --- a/src/Presentation/Tools/AuthorTools.cs +++ b/src/Presentation/Tools/AuthorTools.cs @@ -18,7 +18,7 @@ public static partial class AuthorTools /// The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page. /// The most results to return, from 1 to 50. /// Also return every review each author has written. Leave false unless you need the individual reviews. - [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> SearchAuthors( ISender sender, CancellationToken cancellationToken, diff --git a/src/Presentation/Tools/ItemIcons.cs b/src/Presentation/Tools/ItemIcons.cs new file mode 100644 index 0000000..e7d702a --- /dev/null +++ b/src/Presentation/Tools/ItemIcons.cs @@ -0,0 +1,15 @@ +namespace CleanModelContextProtocol.Presentation.Tools; + +/// +/// 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. +/// +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/"; +} diff --git a/src/Presentation/Tools/MovieTools.cs b/src/Presentation/Tools/MovieTools.cs index 9c16add..061dc87 100644 --- a/src/Presentation/Tools/MovieTools.cs +++ b/src/Presentation/Tools/MovieTools.cs @@ -18,7 +18,7 @@ public static partial class MovieTools /// The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page. /// The most results to return, from 1 to 50. /// Also return every review of each movie. Leave false unless you need the individual reviews. - [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> SearchMovies( ISender sender, CancellationToken cancellationToken, @@ -50,7 +50,7 @@ public static async partial Task> SearchMovies( /// Get one movie with its review count, average stars and every review, including each author's name. /// The movie's id (a GUID). Use movies_search to find it. - [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 GetMovie( ISender sender, Guid movieId, diff --git a/src/Presentation/Tools/ReviewTools.cs b/src/Presentation/Tools/ReviewTools.cs index 8670f41..3955842 100644 --- a/src/Presentation/Tools/ReviewTools.cs +++ b/src/Presentation/Tools/ReviewTools.cs @@ -18,7 +18,7 @@ public static partial class ReviewTools /// Get this one review instead of searching. The other filters are ignored. /// The number of results to skip, 0 for the first page. Pass the nextOffset from a previous call to get the next page. /// The most results to return, from 1 to 50. - [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> ListReviews( ISender sender, CancellationToken cancellationToken, @@ -58,7 +58,7 @@ public static async partial Task> ListReviews( /// The id of the movie being reviewed, needed to create a review. Use movies_search to find it. /// The id of the author writing the review, needed to create a review. Use authors_search to find it. /// The id of an existing review to change the stars of. Leave empty to create a new review. - [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 WriteReview( ISender sender, int stars, @@ -90,7 +90,7 @@ public static async partial Task WriteReview( /// Permanently delete a review. This can't be undone, so confirm with the user first. /// The id of the review to delete. Use reviews_list to find it. - [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 DeleteReview( ISender sender, Guid reviewId, diff --git a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs index 8f23eae..4105250 100644 --- a/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs +++ b/tests/Presentation.Tests.Integration/Completions/CompletionTests.cs @@ -63,10 +63,146 @@ public async Task Complete_WithUnsupportedArgument_ShouldReturn_Nothing(string p } [Fact] - public async Task Complete_ResourceTemplate_ShouldReturn_Nothing() + public async Task CompleteReviewId_ShouldFind_ReviewBeyondTheFirstPage() + { + // Arrange + var review = (await this.CallAsync>("reviews_list", + new Dictionary { ["offset"] = 120, ["limit"] = 1 })).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "reviews://{reviewId}" }, "reviewId", review.Id.ToString()); + + // Assert + result.Completion.Values.ShouldContain(review.Id.ToString()); + } + + [Fact] + public async Task CompleteReviewId_WithEmptyValue_ShouldCap_AtTheProtocolLimit() + { + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "reviews://{reviewId}" }, "reviewId", string.Empty); + + // Assert + result.Completion.Values.Count.ShouldBe(100); + result.Completion.Total.ShouldBe(150); + result.Completion.HasMore.ShouldBe(true); + } + + [Fact] + public async Task CompleteMovieId_ShouldMatch_Title() + { + // Arrange + var movie = (await this.CallAsync>("movies_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "movies://{movieId}" }, "movieId", movie.Title); + + // Assert + result.Completion.Values.ShouldContain(movie.Id.ToString()); + } + + [Fact] + public async Task CompleteMovieId_ShouldMatch_IdPrefix() + { + // Arrange + var movie = (await this.CallAsync>("movies_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "movies://{movieId}" }, "movieId", movie.Id.ToString()[..8]); + + // Assert + result.Completion.Values.ShouldBe([movie.Id.ToString()]); + } + + [Fact] + public async Task CompleteAuthorId_ShouldMatch_Name() + { + // Arrange + var author = (await this.CallAsync>("authors_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "authors://{authorId}" }, "authorId", author.Name); + + // Assert + result.Completion.Values.ShouldContain(author.Id.ToString()); + } + + [Fact] + public async Task CompleteAuthorId_ShouldMatch_IdPrefix() + { + // Arrange + var author = (await this.CallAsync>("authors_search")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "authors://{authorId}" }, "authorId", author.Id.ToString()[..8]); + + // Assert + result.Completion.Values.ShouldBe([author.Id.ToString()]); + } + + [Fact] + public async Task CompleteReviewId_ShouldMatch_IdPrefix() + { + // Arrange + var review = (await this.CallAsync>("reviews_list")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "reviews://{reviewId}" }, "reviewId", review.Id.ToString()[..8]); + + // Assert + result.Completion.Values.ShouldContain(review.Id.ToString()); + } + + [Fact] + public async Task CompleteReviewId_ShouldMatch_MovieTitle() + { + // Arrange + var review = (await this.CallAsync>("reviews_list")).Items[0]; + + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "reviews://{reviewId}" }, "reviewId", review.MovieTitle!); + + // Assert + result.Completion.Values.ShouldContain(review.Id.ToString()); + } + + [Theory] + [InlineData("movies://{movieId}", "movieId")] + [InlineData("authors://{authorId}", "authorId")] + [InlineData("reviews://{reviewId}", "reviewId")] + public async Task CompleteResourceId_WithEmptyValue_ShouldReturn_Ids(string uri, string argument) + { + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = uri }, argument, string.Empty); + + // Assert + result.Completion.Values.ShouldNotBeEmpty(); + + foreach (var value in result.Completion.Values) + { + Guid.TryParse(value, out _).ShouldBeTrue(); + } + } + + [Theory] + [InlineData("movies://{movieId}", "movieId")] + [InlineData("authors://{authorId}", "authorId")] + [InlineData("reviews://{reviewId}", "reviewId")] + public async Task CompleteResourceId_WithNoMatch_ShouldReturn_Nothing(string uri, string argument) + { + // Act + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = uri }, argument, "zzz-no-match"); + + // Assert + result.Completion.Values.ShouldBeEmpty(); + result.Completion.Total.ShouldBe(0); + } + + [Fact] + public async Task CompleteUnknownResourceArgument_ShouldReturn_Nothing() { // Act - var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "movies://{movieId}" }, "movieId", "a"); + var result = await this.Client.CompleteAsync(new ResourceTemplateReference { Uri = "movies://{movieId}" }, "unknown", "a"); // Assert result.Completion.Values.ShouldBeEmpty(); diff --git a/tests/Presentation.Tests.Integration/Tools/ToolListTests.cs b/tests/Presentation.Tests.Integration/Tools/ToolListTests.cs index e052529..1638d03 100644 --- a/tests/Presentation.Tests.Integration/Tools/ToolListTests.cs +++ b/tests/Presentation.Tests.Integration/Tools/ToolListTests.cs @@ -69,6 +69,22 @@ public async Task ListTools_ShouldWrite_PortableSchemas() } } + [Fact] + public async Task EveryItem_ShouldHave_AnIcon() + { + // Act + var tools = await this.Client.ListToolsAsync(); + var prompts = await this.Client.ListPromptsAsync(); + var resources = await this.Client.ListResourcesAsync(); + var templates = await this.Client.ListResourceTemplatesAsync(); + + // Assert + tools.ShouldAllBe(t => t.ProtocolTool.Icons!.Count == 1); + prompts.ShouldAllBe(p => p.ProtocolPrompt.Icons!.Count == 1); + resources.ShouldAllBe(r => r.ProtocolResource.Icons!.Count == 1); + templates.ShouldAllBe(t => t.ProtocolResourceTemplate.Icons!.Count == 1); + } + [Fact] public async Task ServerInfo_ShouldDescribe_Server() { diff --git a/tests/Presentation.Tests.Unit/Completions/CompletionHandlerTests.cs b/tests/Presentation.Tests.Unit/Completions/CompletionHandlerTests.cs new file mode 100644 index 0000000..aee1e2e --- /dev/null +++ b/tests/Presentation.Tests.Unit/Completions/CompletionHandlerTests.cs @@ -0,0 +1,47 @@ +namespace CleanModelContextProtocol.Presentation.Tests.Unit.Completions; + +using Application.Common.Paging; +using Application.Reviews.Entities; +using Application.Reviews.Queries.GetReviews; +using Mediator; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Presentation.Completions; +using Shouldly; +using Xunit; + +public class CompletionHandlerTests +{ + [Fact] + public async Task Complete_WithoutRequest_ShouldReturn_Nothing() + { + // Act + var result = await CompletionHandler.Complete(Substitute.For(), null, CancellationToken.None); + + // Assert + result.Completion.Values.ShouldBeEmpty(); + } + + [Fact] + public async Task Complete_ReviewWithoutMovie_ShouldNot_Match() + { + // Arrange + var review = new Review(Guid.NewGuid(), 5); + var sender = Substitute.For(); + + _ = sender.Send(Arg.Any(), Arg.Any()) + .Returns(new Page([review], 0, Paging.MaxLimit, 1)); + + var request = new CompleteRequestParams + { + Ref = new ResourceTemplateReference { Uri = "reviews://{reviewId}" }, + Argument = new Argument { Name = "reviewId", Value = "no-match" } + }; + + // Act + var result = await CompletionHandler.Complete(sender, request, CancellationToken.None); + + // Assert + result.Completion.Values.ShouldBeEmpty(); + } +}