Skip to content

Commit 73868a6

Browse files
chat: use structured outputs for SearchBookContent MCP tool (#1079)
## Summary Improves structured output consistency across the AI chat RAG pipeline by converting `SearchBookContent` — the primary semantic vector search tool — to use the same hybrid structured content pattern already established by `FindBookHelpForDiagnostic` and `SearchListingsByCode`. ## Changes ### `SearchBookContent` converted to structured output `SearchBookContent` was the only major RAG tool still returning plain text. It now returns a `CallToolResult` with: - `UseStructuredContent = true` and `OutputSchemaType = typeof(SearchBookContentResult)` - `McpToolResultFormatter.GetModelInput()` will now return the JSON `StructuredContent` to the model instead of the formatted text, consistent with all other structured tools - The human-readable text is preserved as the first `Content` block for MCP clients that display it ### "No results" semantics fixed The zero-results path previously returned `McpToolResultFactory.CreateError()` (`IsError = true`). Zero results from a search query is a **valid outcome**, not a tool failure. It now returns `CreateHybridResult` with an empty `Matches: []` list, so the model always receives well-typed JSON conforming to the schema. Validation failures (empty query, query too long, AI service not configured) remain correctly modeled as errors. ### New structured result types Added to `McpToolResults.cs`: - `SearchBookContentMatchResult` — one vector search hit (score, chapter number, heading, text chunk) - `SearchBookContentResult` — wrapper with `Matches` list; this is the advertised output schema ### `OutputSchemaType` made explicit on all `UseStructuredContent` tools Five tools that had `UseStructuredContent = true` but no `OutputSchemaType` were updated: `GetChapterList`, `GetChapterSections`, `GetDirectContentUrl`, `GetNavigationContext`, `GetChapterSummary`. The MCP SDK already auto-infers the output schema from the concrete return type (confirmed by pre-existing integration tests), so this is redundant but makes the intended schema contract explicit at the declaration site — consistent with how `FindBookHelpForDiagnostic` and `SearchListingsByCode` are declared. ### Contract test updated `McpToolContractTests.McpToolsList_StructuredAndHybridTools_AdvertiseOutputSchema` now asserts that `search_book_content` advertises an `outputSchema`, alongside the other structured tools. ## What was NOT changed (and why) - **Main chat response format** — `ResponseTextFormat.CreateJsonSchemaFormat()` does not exist in OpenAI SDK 2.7.0 for the Responses API. Schema-enforced output on the streaming text response isn't possible without switching to the Chat Completions API, and would break markdown rendering anyway. Both subagents agreed this is correct. - **`strictModeEnabled: true` on tool input registration** — Already correct in `AIChatService.cs`, untouched. - **Plain-text tools** (`LookupConcept`, `CheckTopicCoverage`, `FindRelatedSections`, etc.) — These return prose-heavy navigation summaries where structured content wouldn't add value for the model. ## Testing - ✅ Solution builds: 0 errors, 0 warnings - ✅ All 11 chat unit tests pass - ✅ Contract test updated to cover `search_book_content` schema advertisement
1 parent aea80c9 commit 73868a6

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)