Skip to content

Commit a0a907b

Browse files
chat: use structured outputs for SearchBookContent MCP tool
- Convert SearchBookContent from Task<string> to Task<CallToolResult> with UseStructuredContent = true and OutputSchemaType = typeof(SearchBookContentResult), matching the pattern established by FindBookHelpForDiagnostic and SearchListingsByCode - Add SearchBookContentMatchResult and SearchBookContentResult records to McpToolResults.cs - Fix no-results path to return CreateHybridResult with empty Matches list instead of CreateError — zero search results is a valid outcome, not a tool failure - Add explicit OutputSchemaType to GetChapterList, GetChapterSections, GetDirectContentUrl, GetNavigationContext, and GetChapterSummary for documentation clarity (MCP SDK already infers the output schema from the concrete return type, but explicit annotation makes the intended schema contract visible at the declaration site) - Add search_book_content to McpToolContractTests outputSchema presence assertions
1 parent 0d2cb75 commit a0a907b

4 files changed

Lines changed: 44 additions & 19 deletions

File tree

EssentialCSharp.Web.Tests/McpToolContractTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public async Task McpToolsList_StructuredAndHybridTools_AdvertiseOutputSchema()
3737
await Assert.That(GetTool(tools, "get_chapter_summary").TryGetProperty("outputSchema", out _)).IsTrue();
3838
await Assert.That(GetTool(tools, "search_listings_by_code").TryGetProperty("outputSchema", out _)).IsTrue();
3939
await Assert.That(GetTool(tools, "find_book_help_for_diagnostic").TryGetProperty("outputSchema", out _)).IsTrue();
40+
await Assert.That(GetTool(tools, "search_book_content").TryGetProperty("outputSchema", out _)).IsTrue();
4041

4142
await Assert.That(GetTool(tools, "get_section_content").TryGetProperty("outputSchema", out _)).IsFalse();
4243
await Assert.That(GetTool(tools, "get_listing_source_code").TryGetProperty("outputSchema", out _)).IsFalse();

EssentialCSharp.Web/Models/McpToolResults.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
namespace EssentialCSharp.Web.Models;
22

3+
public sealed record SearchBookContentMatchResult(
4+
double Score,
5+
int? ChapterNumber,
6+
string? Heading,
7+
string ChunkText);
8+
9+
public sealed record SearchBookContentResult(
10+
IReadOnlyList<SearchBookContentMatchResult> Matches);
11+
312
public sealed record BookTocItemResult(
413
string Key,
514
string Title,

EssentialCSharp.Web/Tools/BookContentTool.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -136,13 +136,13 @@ public async Task<string> GetListingWithContext(
136136
explanations).ToMcpString();
137137
}
138138

139-
[McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true),
139+
[McpServerTool(Title = "Get Navigation Context", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(NavigationContextToolResult)),
140140
Description("Get the navigation context for a book section: its breadcrumb path, the previous and next sections, its parent section, and its sibling sections. Useful for understanding where a section sits in the book's structure.")]
141141
public NavigationContextToolResult GetNavigationContext(
142142
[Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections to get valid slugs.")] string sectionKey) =>
143143
_bookToolQueryService.GetNavigationContext(sectionKey);
144144

145-
[McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true),
145+
[McpServerTool(Title = "Get Chapter Summary", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(ChapterSummaryToolResult)),
146146
Description("Get a structural overview of a book chapter: its top-level section headings in reading order, and the coding guidelines associated with that chapter. Useful for understanding what a chapter covers before diving in.")]
147147
public ChapterSummaryToolResult GetChapterSummary(
148148
[Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) =>

EssentialCSharp.Web/Tools/BookSearchTool.cs

Lines changed: 32 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -29,57 +29,72 @@ public BookSearchTool(
2929
_bookToolQueryService = bookToolQueryService;
3030
}
3131

32-
[McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false),
32+
[McpServerTool(Title = "Search Book Content", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(SearchBookContentResult)),
3333
Description("Search the Essential C# book content using semantic vector search. Returns relevant text chunks with chapter and heading context. Use this to find information about C# programming concepts covered in the book.")]
34-
public async Task<string> SearchBookContent(
34+
public async Task<CallToolResult> SearchBookContent(
3535
[Description("The search query describing the C# concept or topic to find in the book.")] string query,
3636
[Description("Number of results to return (1–10). Use a higher value for broad topics or comprehensive research; lower for quick lookups.")] int maxResults = AISearchService.DefaultSearchTop,
3737
CancellationToken cancellationToken = default)
3838
{
3939
if (string.IsNullOrWhiteSpace(query))
4040
{
41-
return "Query must not be empty.";
41+
return McpToolResultFactory.CreateError("Query must not be empty.");
4242
}
4343
if (query.Length > 500)
4444
{
45-
return "Query is too long (maximum 500 characters).";
45+
return McpToolResultFactory.CreateError("Query is too long (maximum 500 characters).");
4646
}
4747

4848
if (_SearchService is null)
4949
{
50-
return "Book search is not available in this environment (AI services are not configured).";
50+
return McpToolResultFactory.CreateError("Book search is not available in this environment (AI services are not configured).");
5151
}
5252

53-
List<SearchBookContentMatchTextResult> matches = (await _SearchService.ExecuteVectorSearch(
54-
query,
55-
top: maxResults,
56-
cancellationToken: cancellationToken))
53+
var rawResults = await _SearchService.ExecuteVectorSearch(
54+
query,
55+
top: maxResults,
56+
cancellationToken: cancellationToken);
57+
58+
if (rawResults.Count == 0)
59+
{
60+
return McpToolResultFactory.CreateHybridResult(
61+
"No results found for the given query.",
62+
new SearchBookContentResult([]));
63+
}
64+
65+
List<SearchBookContentMatchTextResult> textMatches = rawResults
5766
.Select(result => new SearchBookContentMatchTextResult(
5867
result.Score ?? 0,
5968
result.Record.ChapterNumber,
6069
result.Record.Heading,
6170
result.Record.ChunkText))
6271
.ToList();
6372

64-
if (matches.Count == 0)
65-
{
66-
return "No results found for the given query.";
67-
}
73+
SearchBookContentResult structuredResult = new(
74+
rawResults
75+
.Select(result => new SearchBookContentMatchResult(
76+
result.Score ?? 0,
77+
result.Record.ChapterNumber,
78+
result.Record.Heading,
79+
result.Record.ChunkText))
80+
.ToList());
6881

69-
return new SearchBookContentTextResult(matches).ToMcpString();
82+
return McpToolResultFactory.CreateHybridResult(
83+
new SearchBookContentTextResult(textMatches).ToMcpString(),
84+
structuredResult);
7085
}
7186

72-
[McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true),
87+
[McpServerTool(Title = "Get Chapter List", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(ChapterListToolResult)),
7388
Description("Get the table of contents for the Essential C# book, listing all chapters and their sections with navigation links.")]
7489
public ChapterListToolResult GetChapterList() => _bookToolQueryService.GetChapterList();
7590

76-
[McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true),
91+
[McpServerTool(Title = "Get Chapter Sections", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(ChapterSectionsToolResult)),
7792
Description("Get all sections and subsections in a specific chapter of the Essential C# book, in reading order. Returns each section's heading, slug, anchor link, and indent level. Use the returned slugs with other tools like GetSectionContent or GetNavigationContext.")]
7893
public ChapterSectionsToolResult GetChapterSections(
7994
[Description("The chapter number (e.g., 5 for Chapter 5).")] int chapter) =>
8095
_bookToolQueryService.GetChapterSections(chapter);
8196

82-
[McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true),
97+
[McpServerTool(Title = "Get Direct Content URL", ReadOnly = true, Destructive = false, Idempotent = true, OpenWorld = false, UseStructuredContent = true, OutputSchemaType = typeof(BookSectionReferenceResult)),
8398
Description("Get the canonical deep-link URL and section metadata for a specific book section or subsection. Use this to include precise references in responses.")]
8499
public BookSectionReferenceResult GetDirectContentUrl(
85100
[Description("The section slug/key (e.g., 'hello-world'). Use GetChapterSections or GetChapterList to find valid slugs.")] string sectionKey) =>

0 commit comments

Comments
 (0)