From 071b7001484d032ef86cc20824b047b1d92aca16 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sun, 8 Mar 2026 09:48:54 +0100 Subject: [PATCH 1/2] Add MCP gateway auto-discovery integrations --- AGENTS.md | 5 + Directory.Packages.props | 7 +- README.md | 669 ++++++------------ ...able-chat-client-and-agent-tool-modules.md | 146 ++++ docs/Architecture/Overview.md | 21 + .../McpGatewayAutoDiscoveryOptions.cs | 18 + .../Serialization/McpGatewayJsonSerializer.cs | 7 +- .../McpGatewayAutoDiscoveryChatClient.cs | 252 +++++++ .../McpGatewayToolSet.cs | 189 +++++ .../McpGatewayChatClientExtensions.cs | 44 ++ .../McpGatewayChatOptionsExtensions.cs | 51 ++ ...cpGatewayAgentFrameworkIntegrationTests.cs | 99 +++ .../McpGatewayChatClientIntegrationTests.cs | 93 +++ .../ManagedCode.MCPGateway.Tests.csproj | 1 + .../Search/McpGatewaySearchBuildTests.cs | 2 +- .../GatewayIntegrationTestSupport.cs | 244 +++++++ .../TestSupport/TestChatClient.cs | 260 ++++++- .../TestSupport/TestEmbeddingGenerator.cs | 7 + 18 files changed, 1655 insertions(+), 460 deletions(-) create mode 100644 docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md create mode 100644 src/ManagedCode.MCPGateway/Configuration/McpGatewayAutoDiscoveryOptions.cs create mode 100644 src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs create mode 100644 src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs create mode 100644 src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs create mode 100644 tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs create mode 100644 tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs create mode 100644 tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs diff --git a/AGENTS.md b/AGENTS.md index 386245c..e4c892d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -130,6 +130,7 @@ If no new rule is detected -> do not update the file. - When the package requires an initialization step such as index building, provide an ergonomic optional integration path (for example DI extension or hosted background warmup) instead of forcing every consumer to call it manually, and document when manual initialization is still appropriate. - Keep documented configuration defaults synchronized with the actual `McpGatewayOptions` defaults; for example, `MaxSearchResults` default is `15`, not stale sample values. - Keep the README focused on package usage and onboarding, not internal implementation notes. +- Keep `README.md` free of unnecessary internal detail; it should stay clear, example-driven, and focused on what consumers need to understand and use the package quickly. - Document optional DI dependencies explicitly in README examples so consumers know which services they must register themselves, such as embedding generators. - Keep README code examples as real example code blocks, not commented-out pseudo-code; if behavior is optional, show it in a separate example instead of commenting lines inside another snippet. - Never leave empty placeholder setup blocks in README examples such as `// gateway configuration`; show a concrete minimal configuration that actually demonstrates the API. @@ -164,6 +165,7 @@ If no new rule is detected -> do not update the file. - Follow `.editorconfig` and repository analyzers. - Keep warnings clean; repository builds treat warnings as errors. +- Always treat local and CI builds as `WarningsAsErrors`; never rely on warnings being acceptable, because this repository expects zero-warning output as a hard quality gate. - Prefer simple, readable C# over clever abstractions. - Prefer modern C# 14 syntax when it improves clarity and keep replacing stale legacy syntax with current idiomatic language constructs instead of preserving older forms by inertia. - Prefer straightforward DI-native constructors in public types; avoid redundant constructor chaining that only wraps `new SomeRuntime(...)` behind a second constructor, because in modern C# this adds ceremony without improving clarity. @@ -179,6 +181,7 @@ If no new rule is detected -> do not update the file. - Prefer explicit SOLID object decomposition over large `partial` types; when responsibilities like registry, indexing, invocation, or schema handling can live in dedicated classes, extract real collaborators instead of only splitting files. - Keep `McpGateway` focused on search/invoke orchestration only; do not embed registry or mutation responsibilities into the gateway type itself, because that mixes lifecycle/catalog mutation with runtime execution concerns. - Keep public API names aligned with package identity `ManagedCode.MCPGateway`. +- For package-scoped public API members, prefer concise names without repeating the `ManagedCode` brand inside method names when the namespace/package already scopes them, because redundant branding makes the API noisy. - Do not duplicate package metadata or version blocks inside project files unless a project-specific override is required. - Use constants for stable tool names and protocol-facing identifiers. - Never leave stable string literals inline in runtime code; extract named constants for diagnostic codes, messages, modes, keys, tool descriptions, and other durable identifiers so changes stay centralized. @@ -197,6 +200,8 @@ If no new rule is detected -> do not update the file. - Never publish to NuGet from the local machine without explicit user confirmation. - Never use destructive git commands without explicit user approval. - Never weaken tests, analyzers, or packaging checks to make CI pass. +- This repository uses `TUnit` on top of `Microsoft.Testing.Platform`, so prefer the `dotnet test --solution ...` commands above. Do not assume VSTest-only flags such as `--filter` or `--logger` are available here. + ### Boundaries diff --git a/Directory.Packages.props b/Directory.Packages.props index f8bcc20..613a00c 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -5,6 +5,7 @@ + @@ -16,7 +17,7 @@ - - + + - + \ No newline at end of file diff --git a/README.md b/README.md index 63a7f76..b39f48d 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,10 @@ `ManagedCode.MCPGateway` is a .NET 10 library that turns local `AITool` instances and remote MCP servers into one searchable execution surface. -The package is built on: +It is built on: - `Microsoft.Extensions.AI` - the official `ModelContextProtocol` .NET SDK -- in-memory descriptor indexing with vector ranking and built-in tokenizer-backed fallback ## Install @@ -20,29 +19,22 @@ The package is built on: dotnet add package ManagedCode.MCPGateway ``` -## Architecture And Decision Records +## What It Gives You -- [Architecture overview](docs/Architecture/Overview.md) -- [ADR-0001: Runtime boundaries and index lifecycle](docs/ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md) -- [ADR-0002: Search ranking and query normalization](docs/ADR/ADR-0002-search-ranking-and-query-normalization.md) -- [Feature spec: Search query normalization and ranking](docs/Features/SearchQueryNormalizationAndRanking.md) +- one gateway for local `AITool` instances and MCP tools +- one search surface with vector ranking when embeddings are available and lexical fallback when they are not +- one invoke surface for both local tools and MCP tools +- runtime registration through `IMcpGatewayRegistry` +- reusable gateway meta-tools for chat clients and agents +- staged tool auto-discovery for chat loops, so models do not need to see the whole catalog at once + +## Core Services + +After `services.AddManagedCodeMcpGateway(...)`, the container exposes: -## What You Get - -- one registry for local tools, stdio MCP servers, HTTP MCP servers, existing `McpClient` instances, or deferred `McpClient` factories -- a DI-native split between `IMcpGateway` for runtime search/invoke and `IMcpGatewayRegistry` for catalog mutation -- descriptor indexing that enriches search with tool name, description, required arguments, and input schema -- lazy index build on the first catalog/search/invoke operation, plus optional eager warmup hooks for startup scenarios -- configurable search strategy with embeddings or tokenizer-backed heuristic ranking -- `SearchStrategy.Auto` by default: use embeddings when available, otherwise fall back to tokenizer-backed ranking automatically -- built-in `ChatGptO200kBase` tokenizer path for tokenizer search and tokenizer fallback -- optional English query normalization before ranking when a keyed search rewrite `IChatClient` is registered -- top 5 matches by default when `maxResults` is not specified -- vector search when an `IEmbeddingGenerator>` is registered -- optional persisted tool embeddings through `IMcpGatewayToolEmbeddingStore` -- token-aware lexical fallback when embeddings are unavailable or vector search cannot complete -- one invoke surface for both local `AIFunction` tools and MCP tools -- optional meta-tools you can hand back to another model as normal `AITool` instances +- `IMcpGateway` for build, list, search, invoke, and meta-tool creation +- `IMcpGatewayRegistry` for adding tools or MCP sources after the container is built +- `McpGatewayToolSet` for reusable `AITool` integration helpers ## Quickstart @@ -76,50 +68,88 @@ await using var serviceProvider = services.BuildServiceProvider(); var gateway = serviceProvider.GetRequiredService(); var search = await gateway.SearchAsync("find github repositories"); -var selectedTool = search.Matches[0]; - var invoke = await gateway.InvokeAsync(new McpGatewayInvokeRequest( - ToolId: selectedTool.ToolId, + ToolId: search.Matches[0].ToolId, Query: "managedcode")); ``` -`AddManagedCodeMcpGateway(...)` does not create or configure an embedding generator for you. Vector ranking is enabled only when the same DI container also has an `IEmbeddingGenerator>`. The gateway first tries the keyed registration `McpGatewayServiceKeys.EmbeddingGenerator` and falls back to any regular registration. Otherwise it stays fully functional and uses lexical ranking. +Important defaults: + +- search is `Auto` by default +- `Auto` uses embeddings when available and lexical fallback otherwise +- the default result size is `5` +- the maximum result size is `15` +- the index is built lazily on first list, search, or invoke + +## Basic Registration + +Register local tools during startup: -The gateway builds its catalog lazily on the first `ListToolsAsync(...)`, `SearchAsync(...)`, or `InvokeAsync(...)` call. If you add more tools later through the registry, the next catalog/search/invoke operation rebuilds the index automatically. You only need an explicit warmup call when you want eager startup validation or a pre-warmed cache. +```csharp +services.AddManagedCodeMcpGateway(options => +{ + options.AddTool( + "local", + AIFunctionFactory.Create( + static (string query) => $"weather:{query}", + new AIFunctionFactoryOptions + { + Name = "weather_search_forecast", + Description = "Search weather forecast and temperature information by city name." + })); +}); +``` -`McpGateway` is the runtime search/invoke facade. If you need to add tools or MCP sources after the container is built, resolve `IMcpGatewayRegistry` separately: +If you need to add tools later, use `IMcpGatewayRegistry`: ```csharp +await using var serviceProvider = services.BuildServiceProvider(); + var registry = serviceProvider.GetRequiredService(); +var gateway = serviceProvider.GetRequiredService(); registry.AddTool( - "local", + "runtime", AIFunctionFactory.Create( - static (string query) => $"weather:{query}", + static (string query) => $"status:{query}", new AIFunctionFactoryOptions { - Name = "weather_search_forecast", - Description = "Search weather forecast and temperature information by city name." + Name = "project_status_lookup", + Description = "Look up project status by identifier or short title." })); var tools = await gateway.ListToolsAsync(); ``` -`AddManagedCodeMcpGateway(...)` registers `IMcpGateway`, `IMcpGatewayRegistry`, and `McpGatewayToolSet`. Add `AddManagedCodeMcpGatewayIndexWarmup()` only when you want hosted eager initialization. +Registry updates automatically invalidate the catalog. The next list, search, or invoke rebuilds the index. + +## Register MCP Sources -## Public Surfaces +`ManagedCode.MCPGateway` supports: -Resolve these services depending on what the host needs: +- local `AITool` / `AIFunction` +- HTTP MCP servers +- stdio MCP servers +- existing `McpClient` instances +- deferred `McpClient` factories -- `IMcpGateway`: build, list, search, invoke, and create meta-tools -- `IMcpGatewayRegistry`: add local tools or MCP sources after the container is built -- `McpGatewayToolSet`: expose the gateway itself as reusable `AITool` instances +Examples: -Those three services deliberately separate runtime execution, catalog mutation, and meta-tool creation instead of collapsing everything into one mutable gateway type. +```csharp +services.AddManagedCodeMcpGateway(options => +{ + options.AddHttpServer( + sourceId: "docs", + endpoint: new Uri("https://example.com/mcp")); -## Register Existing Or Deferred MCP Clients + options.AddStdioServer( + sourceId: "filesystem", + command: "npx", + arguments: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]); +}); +``` -`IMcpGatewayRegistry` supports both immediate `McpClient` instances and deferred client factories: +Or through the runtime registry: ```csharp var registry = serviceProvider.GetRequiredService(); @@ -132,25 +162,26 @@ registry.AddMcpClient( registry.AddMcpClientFactory( sourceId: "work-items", clientFactory: static async cancellationToken => - { - return await CreateWorkItemClientAsync(cancellationToken); - }); + await CreateWorkItemClientAsync(cancellationToken)); ``` -Use `AddMcpClient(...)` when another part of the host already owns the client lifetime. Use `AddMcpClientFactory(...)` when the gateway should lazily create and cache the client through its normal source-loading path. +## Search And Invoke -## Invoke By Tool Id Or Stable Identity +The normal flow is: -The common flow is search first, then invoke by `ToolId`: +1. search +2. choose a match +3. invoke by `ToolId` ```csharp var search = await gateway.SearchAsync("find github repositories"); + var invoke = await gateway.InvokeAsync(new McpGatewayInvokeRequest( ToolId: search.Matches[0].ToolId, Query: "managedcode")); ``` -If the host already knows the stable tool name, invocation can target `ToolName` and optionally `SourceId` instead: +If the host already knows the stable tool name, invocation can target `ToolName` and `SourceId` instead: ```csharp var invoke = await gateway.InvokeAsync(new McpGatewayInvokeRequest( @@ -159,116 +190,11 @@ var invoke = await gateway.InvokeAsync(new McpGatewayInvokeRequest( Query: "managedcode")); ``` -Use `SourceId` when the same tool name may exist in more than one registered source. - -## Optional Eager Warmup - -If you want to warm the catalog immediately after building the container, use the service-provider extension: - -```csharp -await using var serviceProvider = services.BuildServiceProvider(); - -var build = await serviceProvider.InitializeManagedCodeMcpGatewayAsync(); -``` - -`InitializeManagedCodeMcpGatewayAsync()` returns `McpGatewayIndexBuildResult`, so startup code can inspect diagnostics or fail fast explicitly. - -For hosted applications, register background warmup once and let the host trigger it on startup: - -```csharp -var services = new ServiceCollection(); - -services.AddManagedCodeMcpGateway(options => -{ - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); - -services.AddManagedCodeMcpGatewayIndexWarmup(); -``` - -Use eager warmup when you want fail-fast startup behavior, a warmed cache before the first request, or deterministic startup benchmarking. Otherwise the lazy default is enough. - -## Recommended Hosted Setup - -This example shows the full production-oriented integration shape in one place. Remove the optional registrations if your host does not need vector search, query normalization, or persistent embedding reuse. - -```csharp -using ManagedCode.MCPGateway; -using ManagedCode.MCPGateway.Abstractions; -using Microsoft.Extensions.AI; -using Microsoft.Extensions.DependencyInjection; - -var services = new ServiceCollection(); - -services.AddKeyedSingleton>, MyEmbeddingGenerator>( - McpGatewayServiceKeys.EmbeddingGenerator); -services.AddKeyedSingleton( - McpGatewayServiceKeys.SearchQueryChatClient); -services.AddSingleton(); - -services.AddManagedCodeMcpGateway(options => -{ - options.SearchStrategy = McpGatewaySearchStrategy.Auto; - options.SearchQueryNormalization = McpGatewaySearchQueryNormalization.TranslateToEnglishWhenAvailable; - - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); - - options.AddHttpServer( - sourceId: "docs", - endpoint: new Uri("https://example.com/mcp")); -}); - -services.AddManagedCodeMcpGatewayIndexWarmup(); - -await using var serviceProvider = services.BuildServiceProvider(); - -var gateway = serviceProvider.GetRequiredService(); -var registry = serviceProvider.GetRequiredService(); -var metaTools = serviceProvider.GetRequiredService().CreateTools(); - -registry.AddTool( - "runtime", - AIFunctionFactory.Create( - static (string query) => $"status:{query}", - new AIFunctionFactoryOptions - { - Name = "project_status_lookup", - Description = "Look up project status by identifier or short title." - })); - -var search = await gateway.SearchAsync( - new McpGatewaySearchRequest( - Query: "review qeue for managedcode prs", - ContextSummary: "User is looking at repository maintenance work")); -``` - -Notes: - -- `SearchStrategy.Auto` is the default and is usually the right production setting. -- the embedding generator, search-query rewrite client, and embedding store are all optional DI integrations -- hosted warmup is optional; if you omit it, the gateway builds its catalog lazily on first use -- runtime registrations through `IMcpGatewayRegistry` invalidate the catalog automatically, so the next list/search/invoke call rebuilds the index -- `McpGatewayToolSet` and `gateway.CreateMetaTools()` expose the same meta-tools in two integration styles +Use `SourceId` when the same tool name can exist in more than one source. ## Context-Aware Search And Invoke -When the current turn has extra UI, workflow, or chat context, pass it through the request models: +You can pass UI or workflow context into search and invocation: ```csharp var search = await gateway.SearchAsync(new McpGatewaySearchRequest( @@ -292,210 +218,153 @@ var invoke = await gateway.InvokeAsync(new McpGatewayInvokeRequest( })); ``` -The gateway uses this request context in two ways: - -- search combines the query, context summary, and context values into one effective search input for embeddings or lexical fallback -- MCP invocation sends the request context in MCP `meta` -- local `AIFunction` tools can receive auto-mapped `query`, `contextSummary`, and `context` arguments when those parameters are required +This context is used for ranking, and MCP invocations also receive it through MCP `meta`. ## Meta-Tools -You can expose the gateway itself as two reusable `AITool` instances: +The gateway can expose itself as two reusable tools: + +- `gateway_tools_search` +- `gateway_tool_invoke` + +From the gateway: ```csharp var tools = gateway.CreateMetaTools(); ``` -Or resolve the reusable helper from DI: +From DI: ```csharp var toolSet = serviceProvider.GetRequiredService(); var tools = toolSet.CreateTools(); ``` -Custom stable tool names are supported: +You can also attach those tools to existing chat options or tool lists: ```csharp -var tools = gateway.CreateMetaTools( - searchToolName: "workspace_tool_search", - invokeToolName: "workspace_tool_invoke"); -``` - -By default this creates: - -- `gateway_tools_search` -- `gateway_tool_invoke` +var toolSet = serviceProvider.GetRequiredService(); -These tools are useful when another model should first search the gateway catalog and then invoke the selected tool. +var options = new ChatOptions +{ + AllowMultipleToolCalls = false +}.AddMcpGatewayTools(toolSet); -## Search Behavior +var tools = toolSet.AddTools(existingTools); +``` -`ManagedCode.MCPGateway` builds one descriptor document per tool from: +## Why Auto-Discovery -- tool name -- display name -- description -- required arguments -- input schema summaries +Large tool catalogs should not be pushed directly into every model turn. -Default search profile: +The recommended flow is: -- `SearchStrategy = McpGatewaySearchStrategy.Auto` -- `SearchQueryNormalization = McpGatewaySearchQueryNormalization.TranslateToEnglishWhenAvailable` -- `DefaultSearchLimit = 5` -- `MaxSearchResults = 15` +1. expose only `gateway_tools_search` and `gateway_tool_invoke` +2. let the model search the gateway catalog +3. project only the latest matching tools as direct proxy tools +4. replace that discovered set when a new search result arrives -`McpGatewaySearchStrategy.Auto` means: +This keeps prompts smaller and tool choice cleaner while still using the full gateway catalog behind the scenes. -- vector search when an embedding generator is registered -- tokenizer-backed heuristic search when embeddings are unavailable -- tokenizer-backed fallback when vector search cannot complete for a request +## Recommended Chat Integration -The tokenizer-backed mode builds field-aware search documents from tool names, display names, descriptions, required arguments, and schema properties. Ranking then happens in two stages: +For normal chat loops, use the staged wrapper: -- stage 1 retrieval with BM25-style field scoring, tokenizer-term cosine similarity, and character 3-gram similarity -- stage 2 reranking over the candidate pool with calibrated coverage, lexical similarity, approximate typo matching, and tool-name evidence +```csharp +using ManagedCode.MCPGateway; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; -This keeps the search mathematical and tokenizer-driven instead of relying on hand-written query phrase exceptions. The tokenizer-backed path uses the built-in `ChatGptO200kBase` profile for the GPT-4o / ChatGPT tokenizer family. +await using var serviceProvider = services.BuildServiceProvider(); -There is no public tokenizer-selection option. The package ships one built-in tokenizer-backed path and keeps the behavior configurable through search strategy, optional embeddings, and optional English query normalization. +var innerChatClient = serviceProvider.GetRequiredService(); +using var chatClient = innerChatClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => + { + options.MaxDiscoveredTools = 2; + }); -If an embedding generator is registered and vector search is active, the gateway vectorizes descriptor documents and uses cosine similarity plus lexical boosts. It first tries the keyed registration `McpGatewayServiceKeys.EmbeddingGenerator` and then falls back to any regular `IEmbeddingGenerator>`. +var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Find the github search tool and run it.")], + new ChatOptions + { + AllowMultipleToolCalls = false + }); +``` -The embedding generator is resolved per gateway operation, so singleton, scoped, and transient DI registrations all work with index builds and search. +What this does: -### Reading Search Diagnostics +- the first turn only exposes the two gateway meta-tools +- after a search result, the latest matches are exposed as direct proxy tools +- a later search replaces the previous discovered set -`McpGatewaySearchResult` exposes both the ranking mode and diagnostics for the chosen path: +If you already have a search result and want to materialize those proxy tools yourself, use: ```csharp -var result = await gateway.SearchAsync("review qeue for managedcode prs"); - -Console.WriteLine(result.RankingMode); - -foreach (var diagnostic in result.Diagnostics) -{ - Console.WriteLine($"{diagnostic.Code}: {diagnostic.Message}"); -} +var toolSet = serviceProvider.GetRequiredService(); +var discoveredTools = toolSet.CreateDiscoveredTools(search.Matches, maxTools: 3); ``` -Common diagnostics: - -- `query_normalized` -- `lexical_fallback` -- `vector_search_failed` - -## Optional English Query Normalization - -By default, the gateway may rewrite the incoming search query into concise English before ranking: +## Recommended Agent Integration -- it only happens when `options.SearchQueryNormalization` is enabled -- it only uses a keyed `IChatClient` registered as `McpGatewayServiceKeys.SearchQueryChatClient` -- if no keyed chat client is registered, search continues unchanged -- if normalization fails, search continues with the original query and emits a diagnostic - -Preferred registration: +The same chat wrapper works with Microsoft Agent Framework hosts: ```csharp -var services = new ServiceCollection(); - -services.AddKeyedSingleton( - McpGatewayServiceKeys.SearchQueryChatClient, - mySearchRewriteChatClient); - -services.AddManagedCodeMcpGateway(options => -{ - options.SearchStrategy = McpGatewaySearchStrategy.Auto; - options.SearchQueryNormalization = McpGatewaySearchQueryNormalization.TranslateToEnglishWhenAvailable; +using ManagedCode.MCPGateway; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"travel:{query}", - new AIFunctionFactoryOptions - { - Name = "travel_hotel_search", - Description = "Find hotels by city, district, amenities, breakfast, or cancellation policy." - })); -}); -``` +await using var serviceProvider = services.BuildServiceProvider(); -Disable normalization when the host wants purely local tokenizer behavior: +var innerChatClient = serviceProvider.GetRequiredService(); +var loggerFactory = serviceProvider.GetRequiredService(); +using var chatClient = innerChatClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => + { + options.MaxDiscoveredTools = 2; + }); -```csharp -services.AddManagedCodeMcpGateway(options => -{ - options.SearchStrategy = McpGatewaySearchStrategy.Tokenizer; - options.SearchQueryNormalization = McpGatewaySearchQueryNormalization.Disabled; -}); +var agent = new ChatClientAgent( + chatClient, + instructions: "Search the gateway catalog before invoking tools.", + name: "workspace-agent", + tools: [], + loggerFactory: loggerFactory, + services: serviceProvider); + +var response = await agent.RunAsync( + "Find the github search tool and run it.", + session: null, + options: new ChatClientAgentRunOptions(new ChatOptions + { + AllowMultipleToolCalls = false + }), + cancellationToken: default); ``` -The package does not register or configure an `IChatClient` for you. This keeps the gateway generic while still allowing multilingual and typo-heavy search inputs to converge to an English retrieval form when the host opts in. - -`McpGatewaySearchResult.RankingMode` stays: +`ManagedCode.MCPGateway` itself stays generic. The Agent Framework dependency remains in the host project. -- `vector` for embedding-backed ranking -- `lexical` for tokenizer-backed ranking and tokenizer fallback -- `browse` when no search text/context is provided -- `empty` when the catalog is empty +## Optional Warmup -In other words, the current `lexical` mode is the working tokenizer mode. +The gateway works without explicit initialization, but you can warm the index eagerly when you want startup validation or a pre-built cache. -## Search Strategy Matrix - -Use `McpGatewaySearchStrategy.Auto` when you want one production default that works everywhere: - -- if embeddings are registered, use embeddings -- if embeddings are missing, use tokenizer ranking -- if embeddings fail for a query, fall back to tokenizer ranking - -Use `McpGatewaySearchStrategy.Embeddings` when: - -- embeddings are expected in that host -- you want vector search whenever it is available -- tokenizer ranking should only be the fallback path when vector search cannot complete - -Use `McpGatewaySearchStrategy.Tokenizer` when: - -- you want a zero-embedding deployment -- you want deterministic local search behavior without an embedding provider -- you want to benchmark tokenizer-backed ranking independently from vector search - -## Search Strategy Configuration - -Force embeddings when they are available: +Manual warmup: ```csharp -var services = new ServiceCollection(); - -services.AddKeyedSingleton>, MyEmbeddingGenerator>( - McpGatewayServiceKeys.EmbeddingGenerator); - -services.AddManagedCodeMcpGateway(options => -{ - options.SearchStrategy = McpGatewaySearchStrategy.Embeddings; +await using var serviceProvider = services.BuildServiceProvider(); - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); +var build = await serviceProvider.InitializeManagedCodeMcpGatewayAsync(); ``` -Force tokenizer-backed ranking: +Hosted warmup: ```csharp -var services = new ServiceCollection(); - services.AddManagedCodeMcpGateway(options => { - options.SearchStrategy = McpGatewaySearchStrategy.Tokenizer; - options.AddTool( "local", AIFunctionFactory.Create( @@ -506,38 +375,15 @@ services.AddManagedCodeMcpGateway(options => Description = "Search GitHub repositories by user query." })); }); -``` -Keep the default auto strategy, but make the defaults explicit in code: - -```csharp -var services = new ServiceCollection(); - -services.AddManagedCodeMcpGateway(options => -{ - options.SearchStrategy = McpGatewaySearchStrategy.Auto; - options.DefaultSearchLimit = 5; - options.MaxSearchResults = 15; - - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); +services.AddManagedCodeMcpGatewayIndexWarmup(); ``` -If you do not register an embedding generator, the same configuration still works and automatically uses tokenizer ranking. - ## Optional Embeddings -Register any provider-specific implementation of `IEmbeddingGenerator>` in the same DI container before building the service provider. +If the container has `IEmbeddingGenerator>`, the gateway can use vector ranking. -Preferred registration for the gateway: +Preferred registration: ```csharp var services = new ServiceCollection(); @@ -559,158 +405,87 @@ services.AddManagedCodeMcpGateway(options => }); ``` -Fallback when your app already exposes a regular embedding generator: +If no embedding generator is registered, the same gateway still works and falls back to lexical search automatically. -```csharp -var services = new ServiceCollection(); +## Optional Query Normalization -services.AddSingleton>, MyEmbeddingGenerator>(); - -services.AddManagedCodeMcpGateway(options => -{ - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); -``` - -The keyed registration is the preferred one, so you can dedicate a specific embedder to the gateway without affecting other app services. - -## Tokenizer Fallback Without Embeddings - -This is the default operational fallback: +If you want multilingual or noisy queries normalized before ranking, register a keyed `IChatClient`: ```csharp var services = new ServiceCollection(); +services.AddKeyedSingleton( + McpGatewayServiceKeys.SearchQueryChatClient, + mySearchRewriteChatClient); + services.AddManagedCodeMcpGateway(options => { options.SearchStrategy = McpGatewaySearchStrategy.Auto; - - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); + options.SearchQueryNormalization = McpGatewaySearchQueryNormalization.TranslateToEnglishWhenAvailable; }); - -await using var serviceProvider = services.BuildServiceProvider(); -var gateway = serviceProvider.GetRequiredService(); - -var result = await gateway.SearchAsync("review qeue for managedcode prs"); ``` -With no embedding generator registered: - -- the gateway still builds the catalog -- search uses the built-in `ChatGptO200kBase` tokenizer path and two-stage lexical ranking -- an optional keyed search rewrite `IChatClient` can normalize the query to English first -- typo-tolerant term heuristics still participate in ranking -- the result diagnostics contain `lexical_fallback` -- the default result set size is 5 - -If you want tokenizer search only, set `options.SearchStrategy = McpGatewaySearchStrategy.Tokenizer`. +If the keyed chat client is missing or normalization fails, search continues normally. -## Persistent Tool Embeddings +## Optional Persistent Tool Embeddings -For process-local caching, the package already includes `McpGatewayInMemoryToolEmbeddingStore`: +For process-local caching, use the built-in in-memory store: ```csharp -var services = new ServiceCollection(); - services.AddKeyedSingleton>, MyEmbeddingGenerator>( McpGatewayServiceKeys.EmbeddingGenerator); services.AddSingleton(); - -services.AddManagedCodeMcpGateway(options => -{ - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); ``` -If you want to keep descriptor embeddings in a database or another persistent store, register your own `IMcpGatewayToolEmbeddingStore` implementation instead: +For durable caching, register your own `IMcpGatewayToolEmbeddingStore` implementation: ```csharp -var services = new ServiceCollection(); - services.AddKeyedSingleton>, MyEmbeddingGenerator>( McpGatewayServiceKeys.EmbeddingGenerator); services.AddSingleton(); - -services.AddManagedCodeMcpGateway(options => -{ - options.AddTool( - "local", - AIFunctionFactory.Create( - static (string query) => $"github:{query}", - new AIFunctionFactoryOptions - { - Name = "github_search_repositories", - Description = "Search GitHub repositories by user query." - })); -}); ``` -When an index build runs, whether explicitly or through lazy/background warmup, the gateway: - -- computes a descriptor-document hash per tool -- asks `IMcpGatewayToolEmbeddingStore` for matching stored vectors -- generates embeddings only for tools that are missing in the store -- upserts the newly generated vectors back into the store - -This avoids recalculating tool embeddings on every rebuild while still refreshing them automatically when the descriptor document changes. Stored vectors are scoped to both the descriptor hash and the resolved embedding-generator fingerprint, so changing the provider or model automatically forces regeneration. Query embeddings are still generated at search time from the registered `IEmbeddingGenerator>`. - -## Search Evaluation +## Search Modes -The repository includes a tokenizer evaluation suite built around a 50-tool catalog with intentionally overlapping verbs such as `search`, `lookup`, `timeline`, and `summary`, while keeping the domain semantics separated in the descriptions. +`McpGatewaySearchStrategy.Auto` is the default and usually the right choice: -Coverage buckets in `tests/ManagedCode.MCPGateway.Tests/Search/McpGatewayTokenizerSearchEvaluationTests.cs`: +- use vector ranking when embeddings are available +- fall back to lexical ranking when they are not -- high relevance -- borderline / semantically adjacent tools -- multilingual -- typo / spelling mistakes -- weak-intent / underspecified commands -- irrelevant queries +You can also force a mode: -The evaluation asserts: +```csharp +services.AddManagedCodeMcpGateway(options => +{ + options.SearchStrategy = McpGatewaySearchStrategy.Tokenizer; +}); +``` -- `top1`, `top3`, and `top5` -- mean reciprocal rank -- low-confidence behavior for irrelevant queries +Or: -The noisy-query buckets intentionally include spelling mistakes and weakly specified commands so the tokenizer path is exercised as a real fallback, not only on clean benchmark phrasing. +```csharp +services.AddManagedCodeMcpGateway(options => +{ + options.SearchStrategy = McpGatewaySearchStrategy.Embeddings; +}); +``` -Current reference numbers from the repository test corpus: +`McpGatewaySearchResult.RankingMode` reports: -- `ChatGptO200kBase`: high relevance `top1=95.65%`, `top3=100%`, `top5=100%`, `MRR=0.98`; typo `top1=100%`; weak intent `top1=100%`; irrelevant `low-confidence=100%` +- `vector` +- `lexical` +- `browse` +- `empty` -## Supported Sources +## Deeper Docs -- local `AITool` / `AIFunction` -- HTTP MCP servers -- stdio MCP servers -- existing `McpClient` instances -- deferred `McpClient` factories +Use these when you need design details rather than package onboarding: +- [Architecture overview](docs/Architecture/Overview.md) +- [ADR-0001: Runtime boundaries and index lifecycle](docs/ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md) +- [ADR-0002: Search ranking and query normalization](docs/ADR/ADR-0002-search-ranking-and-query-normalization.md) +- [ADR-0003: Reusable chat-client and agent auto-discovery modules](docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md) +- [Feature spec: Search query normalization and ranking](docs/Features/SearchQueryNormalizationAndRanking.md) ## Local Development @@ -725,11 +500,3 @@ Analyzer pass: ```bash dotnet build ManagedCode.MCPGateway.slnx -c Release --no-restore -p:RunAnalyzers=true ``` - -Detailed TUnit output: - -```bash -dotnet test --solution ManagedCode.MCPGateway.slnx -c Release --no-build --output Detailed --no-progress -``` - -This repository uses `TUnit` on top of `Microsoft.Testing.Platform`, so prefer the `dotnet test --solution ...` commands above. Do not assume VSTest-only flags such as `--filter` or `--logger` are available here. diff --git a/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md b/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md new file mode 100644 index 0000000..b813a9d --- /dev/null +++ b/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md @@ -0,0 +1,146 @@ +# ADR-0003: Reusable Chat-Client And Agent Tool Modules + +## Context + +`ManagedCode.MCPGateway` already exposes the gateway as two reusable meta-tools through `McpGatewayToolSet` and `IMcpGateway.CreateMetaTools(...)`. + +The package now also needs to prove that these tools integrate cleanly with two host-side consumption patterns: + +- direct `IChatClient` tool-calling +- Microsoft Agent Framework agents that accept an `IChatClient` + +The user explicitly asked for: + +- very lightweight host integration +- deterministic scenario-driven tests +- coverage both without embeddings and with embeddings +- a staged auto-discovery flow where the model starts with only two gateway tools, then receives only the currently needed direct tools, and then sees those discovered tools replaced when a new search result arrives + +At the same time, the repository still wants to keep the core package generic, library-first, and free from unnecessary host-framework dependencies. + +## Decision + +`ManagedCode.MCPGateway` will keep the core integration surface generic around reusable `AITool` modules: + +- `McpGatewayToolSet.CreateTools(...)` remains the source of truth for the gateway meta-tools +- `McpGatewayToolSet.AddTools(...)` composes those tools into an existing `IList` without duplicating names +- `ChatOptions.AddMcpGatewayTools(...)` attaches the same tools to chat-client requests +- `McpGatewayToolSet.CreateDiscoveredTools(...)` projects the latest search matches as direct proxy tools +- `McpGatewayAutoDiscoveryChatClient` and `UseManagedCodeMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper for both plain `IChatClient` and Agent Framework hosts + +The recommended host flow is: + +1. expose only `gateway_tools_search` and `gateway_tool_invoke` +2. let the model search the gateway +3. project only the latest search matches as direct proxy tools +4. replace that discovered proxy set when a new search result arrives + +The core package will not take a hard runtime dependency on Microsoft Agent Framework just to provide agent-specific sugar. Agent hosts consume the same generic `IChatClient` wrapper. + +## Diagram + +```mermaid +flowchart LR + Gateway["IMcpGateway / McpGateway"] --> ToolSet["McpGatewayToolSet"] + ToolSet --> MetaTools["gateway_tools_search + gateway_tool_invoke"] + ToolSet --> Discovered["CreateDiscoveredTools(...)"] + MetaTools --> ChatOptions["ChatOptions.AddMcpGatewayTools(...)"] + MetaTools --> AutoDiscovery["McpGatewayAutoDiscoveryChatClient"] + Discovered --> AutoDiscovery + AutoDiscovery --> ChatClient["IChatClient host"] + AutoDiscovery --> Agent["ChatClientAgent or other host agent"] +``` + +## Alternatives + +### Alternative 1: Add Microsoft Agent Framework as a hard dependency of the core package + +Pros: + +- direct agent-specific extension methods in the base package +- fewer lines of host composition code for Agent Framework consumers + +Cons: + +- expands the dependency surface for every gateway consumer +- couples a generic library to one host framework +- makes the core package track preview or fast-moving agent APIs unnecessarily + +### Alternative 2: Add a host-specific runtime abstraction inside `ManagedCode.MCPGateway` + +Pros: + +- one “gateway-aware agent” concept in the package +- room for framework-specific behavior later + +Cons: + +- duplicates what host frameworks already do with `AITool` +- adds app-host concerns to a library-first package +- obscures the simple search-then-invoke tool model + +### Alternative 3: Expose every gateway catalog tool directly as a separate runtime `AITool` + +Pros: + +- models see the full catalog explicitly +- no search step required for small catalogs + +Cons: + +- large catalogs become expensive for models and agents +- duplicates schema/metadata projection work +- weakens the package’s intended gateway pattern of semantic search followed by stable invocation + +## Consequences + +Positive: + +- direct `IChatClient` hosts get a one-line staged auto-discovery wrapper +- agent hosts can reuse the same staged wrapper without a separate host-specific package module +- the core package stays generic and avoids a hard Agent Framework dependency +- deterministic tests can validate both chat-client and agent loops against the same 50-tool gateway catalog in lexical fallback mode and vector mode + +Trade-offs: + +- the auto-discovery wrapper owns one more piece of host orchestration inside the base package +- the exposed direct tools are ephemeral proxies for the latest search result, not a permanent export of the full catalog + +Mitigations: + +- keep README examples for both `IChatClient` and Agent Framework +- keep `McpGatewayToolSet.AddTools(...)` and `ChatOptions.AddMcpGatewayTools(...)` as the low-level escape hatch +- keep integration tests covering both host patterns with a scenario-driven test chat client and both search modes + +## Invariants + +- `McpGatewayToolSet.CreateTools(...)` MUST remain the canonical source of gateway meta-tools. +- `McpGatewayToolSet.AddTools(...)` MUST preserve existing tool entries and MUST avoid duplicate names. +- `ChatOptions.AddMcpGatewayTools(...)` MUST preserve existing `ChatOptions.Tools` entries and MUST avoid duplicate names. +- `McpGatewayAutoDiscoveryChatClient` MUST start each host loop with only the two gateway meta-tools visible unless the host already supplied other non-gateway tools. +- `McpGatewayAutoDiscoveryChatClient` MUST replace the discovered proxy-tool set when a newer gateway search result is present instead of accumulating old discovered tools forever. +- The core package MUST stay generic around `AITool` composition and MUST NOT require Microsoft Agent Framework for normal package use. +- Chat-client and agent integration tests MUST prove the staged auto-discovery lifecycle against a realistic multi-tool catalog in both lexical fallback mode and vector mode. + +## Rollout And Rollback + +Rollout: + +1. Keep `McpGatewayToolSet` as the reusable module entry point. +2. Add `McpGatewayAutoDiscoveryChatClient` and `UseManagedCodeMcpGatewayAutoDiscovery(...)` as the recommended host wrapper. +3. Document both chat-client and agent composition examples in `README.md`. +4. Keep architecture docs aligned with the generic `AITool`-module approach. + +Rollback: + +1. Remove the chat-options bridge only if the package intentionally stops supporting direct `IChatClient` tool composition. +2. Remove the auto-discovery wrapper only if the package intentionally stops supporting staged host-side tool visibility. +3. Add a hard Agent Framework dependency only if there is an explicit product decision to make Agent Framework a first-class runtime dependency of the base package. + +## Verification + +- `dotnet restore ManagedCode.MCPGateway.slnx` +- `dotnet format ManagedCode.MCPGateway.slnx` +- `dotnet build ManagedCode.MCPGateway.slnx -c Release --no-restore` +- `dotnet build ManagedCode.MCPGateway.slnx -c Release --no-restore -p:RunAnalyzers=true` +- `dotnet test --solution ManagedCode.MCPGateway.slnx -c Release --no-build` diff --git a/docs/Architecture/Overview.md b/docs/Architecture/Overview.md index 5befc98..3984af6 100644 --- a/docs/Architecture/Overview.md +++ b/docs/Architecture/Overview.md @@ -26,6 +26,8 @@ Out of scope: `McpGateway` stays a thin facade over `McpGatewayRuntime`, which reads immutable catalog snapshots, coordinates vector or tokenizer-backed search, optionally rewrites queries through a keyed `IChatClient`, and invokes local or MCP tools. Optional startup warmup is available through a service-provider extension or hosted background service without changing the lazy default. +The package also keeps chat-client and agent integration generic: `McpGatewayToolSet` is the source of reusable `AITool` meta-tools and discovered proxy tools, `ChatOptions.AddMcpGatewayTools(...)` remains the low-level bridge, and `McpGatewayAutoDiscoveryChatClient` plus `UseManagedCodeMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper that starts with two meta-tools and replaces the discovered proxy set on each new search result without introducing a hard Agent Framework dependency into the core package. + ## System And Module Map ```mermaid @@ -34,8 +36,11 @@ flowchart LR DI --> Facade["IMcpGateway / McpGateway"] DI --> Registry["IMcpGatewayRegistry / McpGatewayRegistry"] DI --> ToolSet["McpGatewayToolSet"] + DI --> AutoDiscovery["Auto-discovery chat client bridge"] DI --> Warmup["Optional warmup hooks"] ToolSet --> Facade + AutoDiscovery --> ToolSet + AutoDiscovery --> HostChat["Host IChatClient / Agent host"] Warmup --> Facade Facade --> Runtime["Internal runtime orchestration"] Runtime --> Catalog["Internal catalog snapshots"] @@ -55,6 +60,11 @@ flowchart LR IMcpGateway["IMcpGateway"] --> McpGateway["McpGateway"] IMcpGatewayRegistry["IMcpGatewayRegistry"] --> Registry["McpGatewayRegistry"] ToolSet["McpGatewayToolSet"] --> IMcpGateway + ToolSet --> ToolList["IList composition"] + ToolSet --> DiscoveredTools["CreateDiscoveredTools(...)"] + ChatOptions["ChatOptions.AddMcpGatewayTools(...)"] --> ToolSet + AutoDiscovery["McpGatewayAutoDiscoveryChatClient / UseManagedCodeMcpGatewayAutoDiscovery(...)"] --> ToolSet + AutoDiscovery --> ChatClient Warmup["McpGatewayServiceProviderExtensions / McpGatewayIndexWarmupService"] --> IMcpGateway McpGateway --> Runtime["McpGatewayRuntime"] Runtime --> SearchRequest["McpGatewaySearchRequest"] @@ -71,6 +81,9 @@ flowchart LR ```mermaid flowchart LR McpGateway["McpGateway"] --> McpGatewayRuntime["McpGatewayRuntime"] + AutoDiscovery["McpGatewayAutoDiscoveryChatClient"] --> ToolSet["McpGatewayToolSet"] + AutoDiscovery --> RequestWrapper["AutoDiscoveryRequestChatClient"] + RequestWrapper --> RuntimeTools["gateway_tools_search + discovered proxy tools"] McpGatewayRuntime --> RuntimeCore["Internal/Runtime/Core/*"] McpGatewayRuntime --> RuntimeCatalog["Internal/Runtime/Catalog/*"] McpGatewayRuntime --> RuntimeSearch["Internal/Runtime/Search/*"] @@ -92,6 +105,9 @@ flowchart LR - Public models: [`src/ManagedCode.MCPGateway/Models/`](../../src/ManagedCode.MCPGateway/Models/) contains request/result contracts and enums grouped by search, invocation, catalog, and embeddings behavior. - Public embeddings: [`src/ManagedCode.MCPGateway/Embeddings/`](../../src/ManagedCode.MCPGateway/Embeddings/) provides optional embedding-store implementations. - Public meta-tools: [`src/ManagedCode.MCPGateway/McpGatewayToolSet.cs`](../../src/ManagedCode.MCPGateway/McpGatewayToolSet.cs) exposes the gateway as reusable `AITool` instances for model-driven search and invoke flows. +- Public chat-options bridge: [`src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs`](../../src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs) attaches the gateway meta-tools to `ChatOptions` without replacing existing tools. +- Public auto-discovery wrapper: [`src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs`](../../src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs) stages model-visible tools as `2 meta-tools -> latest discovered proxies -> replace on next search`. +- Public chat-client extensions: [`src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs`](../../src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs) wraps any `IChatClient` with the recommended staged auto-discovery flow. - Internal catalog module: [`src/ManagedCode.MCPGateway/Internal/Catalog/`](../../src/ManagedCode.MCPGateway/Internal/Catalog/) owns mutable tool-source registration state and read-only snapshots for indexing. - Internal catalog sources: [`src/ManagedCode.MCPGateway/Internal/Catalog/Sources/`](../../src/ManagedCode.MCPGateway/Internal/Catalog/Sources/) owns transport-specific source registrations and MCP client creation. - Internal runtime module: [`src/ManagedCode.MCPGateway/Internal/Runtime/`](../../src/ManagedCode.MCPGateway/Internal/Runtime/) owns orchestration and is split by core, catalog, search, invocation, and embeddings concerns. @@ -108,6 +124,9 @@ flowchart LR - `Internal/Catalog/Sources` owns MCP transport-specific creation and caching. Transport setup must not leak into `Internal/Runtime`, `Models`, or `Configuration`. - `Internal/Runtime` may depend on `Internal/Catalog`, `Internal/Embeddings`, `Embeddings`, `Models`, `Configuration`, and `Abstractions`. - Optional AI services such as embedding generators and query-normalization chat clients must stay outside the package core and be resolved through DI service keys rather than hardwired provider code. +- Chat-client and agent integrations must stay `AITool`-centric in the core package. Host-specific frameworks may consume those tools, but the base package should not take a hard dependency on a specific agent host unless that becomes an explicit product decision. +- `McpGatewayAutoDiscoveryChatClient` may orchestrate tool visibility for host chat loops, but it must stay generic over `IChatClient` and must not take a dependency on Microsoft Agent Framework. +- The recommended staged host flow is: advertise only the two gateway meta-tools first, then project only the latest search matches as direct proxy tools, then replace that discovered set on the next search result. - `Models` should stay contract-first. Internal transport, registry, or lifecycle helpers do not belong there. - Embedding support must stay optional and isolated behind `IMcpGatewayToolEmbeddingStore` and embedding-generator abstractions. - Warmup remains optional. The package must work correctly with lazy indexing and must not require manual initialization for every host. @@ -116,11 +135,13 @@ flowchart LR - [`docs/ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md`](../ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md): documents the public/runtime/catalog split, DI boundaries, lazy indexing, cancellation-aware single-flight builds, and optional warmup hooks. - [`docs/ADR/ADR-0002-search-ranking-and-query-normalization.md`](../ADR/ADR-0002-search-ranking-and-query-normalization.md): documents the default `Auto` search behavior, tokenizer-backed fallback, optional English query normalization, and mathematical ranking strategy. +- [`docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md`](../ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md): documents why chat-client and agent integrations stay generic around reusable `AITool` modules instead of adding a hard Agent Framework dependency to the core package. ## Related Docs - [`README.md`](../../README.md) - [`docs/ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md`](../ADR/ADR-0001-runtime-boundaries-and-index-lifecycle.md) - [`docs/ADR/ADR-0002-search-ranking-and-query-normalization.md`](../ADR/ADR-0002-search-ranking-and-query-normalization.md) +- [`docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md`](../ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md) - [`docs/Features/SearchQueryNormalizationAndRanking.md`](../Features/SearchQueryNormalizationAndRanking.md) - [`AGENTS.md`](../../AGENTS.md) diff --git a/src/ManagedCode.MCPGateway/Configuration/McpGatewayAutoDiscoveryOptions.cs b/src/ManagedCode.MCPGateway/Configuration/McpGatewayAutoDiscoveryOptions.cs new file mode 100644 index 0000000..767e121 --- /dev/null +++ b/src/ManagedCode.MCPGateway/Configuration/McpGatewayAutoDiscoveryOptions.cs @@ -0,0 +1,18 @@ +namespace ManagedCode.MCPGateway; + +public sealed class McpGatewayAutoDiscoveryOptions +{ + public int MaxDiscoveredTools { get; set; } = 8; + + public string SearchToolName { get; set; } = McpGatewayToolSet.DefaultSearchToolName; + + public string InvokeToolName { get; set; } = McpGatewayToolSet.DefaultInvokeToolName; + + internal McpGatewayAutoDiscoveryOptions Clone() + => new() + { + MaxDiscoveredTools = MaxDiscoveredTools, + SearchToolName = SearchToolName, + InvokeToolName = InvokeToolName + }; +} diff --git a/src/ManagedCode.MCPGateway/Internal/Serialization/McpGatewayJsonSerializer.cs b/src/ManagedCode.MCPGateway/Internal/Serialization/McpGatewayJsonSerializer.cs index a57bf39..18b03ec 100644 --- a/src/ManagedCode.MCPGateway/Internal/Serialization/McpGatewayJsonSerializer.cs +++ b/src/ManagedCode.MCPGateway/Internal/Serialization/McpGatewayJsonSerializer.cs @@ -1,5 +1,6 @@ using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace ManagedCode.MCPGateway; @@ -54,7 +55,11 @@ internal static class McpGatewayJsonSerializer } private static JsonSerializerOptions CreateOptions() - => new(JsonSerializerDefaults.Web); + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } private static JsonElement? NormalizeElement(JsonElement element) => element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined diff --git a/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs b/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs new file mode 100644 index 0000000..2a72327 --- /dev/null +++ b/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs @@ -0,0 +1,252 @@ +using System.Text.Json; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ManagedCode.MCPGateway; + +public sealed class McpGatewayAutoDiscoveryChatClient : IChatClient +{ + private readonly IChatClient _innerClient; + private readonly ILoggerFactory _loggerFactory; + private readonly IServiceProvider _functionInvocationServices; + private readonly McpGatewayToolSet _toolSet; + private readonly McpGatewayAutoDiscoveryOptions _options; + + public McpGatewayAutoDiscoveryChatClient( + IChatClient innerClient, + McpGatewayToolSet toolSet, + ILoggerFactory? loggerFactory = null, + IServiceProvider? functionInvocationServices = null, + McpGatewayAutoDiscoveryOptions? options = null) + { + ArgumentNullException.ThrowIfNull(innerClient); + ArgumentNullException.ThrowIfNull(toolSet); + + _innerClient = innerClient; + _toolSet = toolSet; + _loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; + _functionInvocationServices = functionInvocationServices ?? EmptyServiceProvider.Instance; + _options = options?.Clone() ?? new McpGatewayAutoDiscoveryOptions(); + } + + public async Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + using var functionInvokingChatClient = CreateFunctionInvokingChatClient(); + return await functionInvokingChatClient.GetResponseAsync(messages, options, cancellationToken); + } + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + using var functionInvokingChatClient = CreateFunctionInvokingChatClient(); + + await foreach (var update in functionInvokingChatClient.GetStreamingResponseAsync(messages, options, cancellationToken)) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + + if (serviceKey is null && serviceType.IsInstanceOfType(this)) + { + return this; + } + + return _innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() => _innerClient.Dispose(); + + private FunctionInvokingChatClient CreateFunctionInvokingChatClient() + { + FunctionInvokingChatClient? functionInvokingChatClient = null; + + var requestClient = new AutoDiscoveryRequestChatClient( + _innerClient, + _toolSet, + _options, + gatewayTools => + { + if (functionInvokingChatClient is not null) + { + functionInvokingChatClient.AdditionalTools = gatewayTools.ToList(); + } + }); + + functionInvokingChatClient = new FunctionInvokingChatClient( + requestClient, + _loggerFactory, + _functionInvocationServices); + + return functionInvokingChatClient; + } + + private sealed class AutoDiscoveryRequestChatClient( + IChatClient innerClient, + McpGatewayToolSet toolSet, + McpGatewayAutoDiscoveryOptions options, + Action> updateAdditionalTools) : IChatClient + { + private readonly IChatClient _innerClient = innerClient; + private readonly McpGatewayToolSet _toolSet = toolSet; + private readonly McpGatewayAutoDiscoveryOptions _options = options; + private readonly Action> _updateAdditionalTools = updateAdditionalTools; + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var messageList = messages as IReadOnlyList ?? messages.ToList(); + var gatewayTools = CreateGatewayTools(messageList); + _updateAdditionalTools(gatewayTools); + + return _innerClient.GetResponseAsync( + messageList, + CreateOptions(options, gatewayTools), + cancellationToken); + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var messageList = messages as IReadOnlyList ?? messages.ToList(); + var gatewayTools = CreateGatewayTools(messageList); + _updateAdditionalTools(gatewayTools); + + return _innerClient.GetStreamingResponseAsync( + messageList, + CreateOptions(options, gatewayTools), + cancellationToken); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + ArgumentNullException.ThrowIfNull(serviceType); + return _innerClient.GetService(serviceType, serviceKey); + } + + public void Dispose() + { + } + + private IReadOnlyList CreateGatewayTools(IReadOnlyList messages) + { + var gatewayTools = _toolSet + .CreateTools(_options.SearchToolName, _options.InvokeToolName) + .ToList(); + + var latestSearchResult = FindLatestSearchResult(messages); + if (latestSearchResult?.Matches.Count is not > 0) + { + return gatewayTools; + } + + var reservedToolNames = new HashSet( + gatewayTools.Select(static tool => tool.Name), + StringComparer.OrdinalIgnoreCase); + + foreach (var discoveredTool in _toolSet.CreateDiscoveredTools( + latestSearchResult.Matches, + reservedToolNames, + Math.Max(0, _options.MaxDiscoveredTools))) + { + gatewayTools.Add(discoveredTool); + } + + return gatewayTools; + } + + private static ChatOptions CreateOptions(ChatOptions? options, IReadOnlyList gatewayTools) + { + var effectiveOptions = options?.Clone() ?? new ChatOptions(); + var effectiveTools = effectiveOptions.Tools is { Count: > 0 } + ? effectiveOptions.Tools.ToList() + : []; + + foreach (var gatewayTool in gatewayTools) + { + if (effectiveTools.Any(existingTool => + string.Equals(existingTool.Name, gatewayTool.Name, StringComparison.OrdinalIgnoreCase))) + { + continue; + } + + effectiveTools.Add(gatewayTool); + } + + effectiveOptions.Tools = effectiveTools; + return effectiveOptions; + } + + private McpGatewaySearchResult? FindLatestSearchResult(IReadOnlyList messages) + { + var functionNamesByCallId = new Dictionary(StringComparer.OrdinalIgnoreCase); + McpGatewaySearchResult? latestSearchResult = null; + + foreach (var message in messages) + { + foreach (var content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + functionNamesByCallId[functionCall.CallId] = functionCall.Name; + break; + case FunctionResultContent functionResult + when functionNamesByCallId.TryGetValue(functionResult.CallId, out var functionName) && + string.Equals(functionName, _options.SearchToolName, StringComparison.OrdinalIgnoreCase): + latestSearchResult = ReadSearchResult(functionResult.Result); + break; + } + } + } + + return latestSearchResult; + } + + private static McpGatewaySearchResult? ReadSearchResult(object? result) + { + if (result is McpGatewaySearchResult searchResult) + { + return searchResult; + } + + var serialized = McpGatewayJsonSerializer.TrySerializeToElement(result); + if (serialized is not JsonElement jsonElement) + { + return null; + } + + return jsonElement.Deserialize(McpGatewayJsonSerializer.Options); + } + } + + private sealed class EmptyServiceProvider : IServiceProvider + { + public static EmptyServiceProvider Instance { get; } = new(); + + public object? GetService(Type serviceType) + { + ArgumentNullException.ThrowIfNull(serviceType); + return null; + } + } +} diff --git a/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs b/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs index 07be5e3..4564165 100644 --- a/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs +++ b/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs @@ -7,8 +7,18 @@ public sealed class McpGatewayToolSet(IMcpGateway gateway) { public const string DefaultSearchToolName = "gateway_tools_search"; public const string DefaultInvokeToolName = "gateway_tool_invoke"; + public const string DiscoveredToolIdPropertyName = "ManagedCode.MCPGateway.ToolId"; + public const string DiscoveredToolSourceIdPropertyName = "ManagedCode.MCPGateway.SourceId"; + public const string DiscoveredToolKindPropertyName = "ManagedCode.MCPGateway.Kind"; public const string SearchToolDescription = "Search the gateway catalog and return the best matching tools for a user task."; public const string InvokeToolDescription = "Invoke a gateway tool by tool id. Search first when the correct tool is unknown."; + private const string DiscoveredToolKindValue = "gateway_discovered_tool"; + private const string DiscoveredToolNameSeparator = "_"; + private const string DiscoveredToolDescriptionPrefix = "Direct proxy for gateway tool "; + private const string DiscoveredToolIdLabel = " ("; + private const string DiscoveredToolDescriptionSeparator = "). "; + private const string DiscoveredToolRequiredArgumentsLabel = "Required arguments: "; + private const string DiscoveredToolArgumentsHint = "Pass named inputs via 'arguments' and use 'query' for free-text tool inputs when supported."; public IReadOnlyList CreateTools( string searchToolName = DefaultSearchToolName, @@ -33,6 +43,73 @@ public IReadOnlyList CreateTools( return [searchTool, invokeTool]; } + public IList AddTools( + IList tools, + string searchToolName = DefaultSearchToolName, + string invokeToolName = DefaultInvokeToolName) + { + ArgumentNullException.ThrowIfNull(tools); + + var targetTools = tools.IsReadOnly ? new List(tools) : tools; + var toolNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var tool in targetTools) + { + toolNames.Add(tool.Name); + } + + foreach (var tool in CreateTools(searchToolName, invokeToolName)) + { + if (toolNames.Add(tool.Name)) + { + targetTools.Add(tool); + } + } + + return targetTools; + } + + public IReadOnlyList CreateDiscoveredTools( + IEnumerable matches, + IReadOnlyCollection? reservedToolNames = null, + int? maxTools = null) + { + ArgumentNullException.ThrowIfNull(matches); + + var toolLimit = maxTools.GetValueOrDefault(int.MaxValue); + if (toolLimit <= 0) + { + return []; + } + + var discoveredTools = new List(); + var reservedNames = new HashSet(StringComparer.OrdinalIgnoreCase); + + if (reservedToolNames is not null) + { + foreach (var reservedToolName in reservedToolNames) + { + if (!string.IsNullOrWhiteSpace(reservedToolName)) + { + reservedNames.Add(reservedToolName); + } + } + } + + foreach (var match in matches) + { + if (discoveredTools.Count == toolLimit) + { + break; + } + + var functionName = CreateDiscoveredToolName(match, reservedNames); + discoveredTools.Add(CreateDiscoveredTool(match, functionName)); + } + + return discoveredTools; + } + public Task SearchAsync( string query, int? maxResults = null, @@ -62,4 +139,116 @@ public Task InvokeAsync( Context: context, ContextSummary: contextSummary), cancellationToken); + + private AITool CreateDiscoveredTool( + McpGatewaySearchMatch match, + string functionName) + { + Task InvokeDiscoveredToolAsync( + Dictionary? arguments = null, + string? query = null, + Dictionary? context = null, + string? contextSummary = null, + CancellationToken cancellationToken = default) + => gateway.InvokeAsync( + new McpGatewayInvokeRequest( + ToolId: match.ToolId, + Arguments: arguments, + Query: query, + Context: context, + ContextSummary: contextSummary), + cancellationToken); + + return AIFunctionFactory.Create( + (Func?, string?, Dictionary?, string?, CancellationToken, Task>)InvokeDiscoveredToolAsync, + new AIFunctionFactoryOptions + { + Name = functionName, + Description = BuildDiscoveredToolDescription(match), + AdditionalProperties = new Dictionary + { + [DiscoveredToolIdPropertyName] = match.ToolId, + [DiscoveredToolSourceIdPropertyName] = match.SourceId, + [DiscoveredToolKindPropertyName] = DiscoveredToolKindValue + } + }); + } + + private static string BuildDiscoveredToolDescription(McpGatewaySearchMatch match) + { + var description = $"{DiscoveredToolDescriptionPrefix}{match.ToolName}{DiscoveredToolIdLabel}{match.ToolId}{DiscoveredToolDescriptionSeparator}{match.Description}"; + if (match.RequiredArguments.Count == 0) + { + return $"{description} {DiscoveredToolArgumentsHint}"; + } + + return $"{description} {DiscoveredToolRequiredArgumentsLabel}{BuildRequiredArgumentList(match.RequiredArguments)}. {DiscoveredToolArgumentsHint}"; + } + + private static string BuildRequiredArgumentList(IReadOnlyList requiredArguments) + { + if (requiredArguments.Count == 1) + { + return requiredArguments[0]; + } + + var builder = new System.Text.StringBuilder(); + for (var index = 0; index < requiredArguments.Count; index++) + { + if (index > 0) + { + builder.Append(", "); + } + + builder.Append(requiredArguments[index]); + } + + return builder.ToString(); + } + + private static string CreateDiscoveredToolName( + McpGatewaySearchMatch match, + ISet reservedNames) + { + ArgumentNullException.ThrowIfNull(match); + ArgumentNullException.ThrowIfNull(reservedNames); + + var sanitizedToolName = SanitizeToolName(match.ToolName); + if (reservedNames.Add(sanitizedToolName)) + { + return sanitizedToolName; + } + + var sanitizedSourceId = SanitizeToolName(match.SourceId); + var compositeName = $"{sanitizedSourceId}{DiscoveredToolNameSeparator}{sanitizedToolName}"; + if (reservedNames.Add(compositeName)) + { + return compositeName; + } + + for (var suffix = 2; ; suffix++) + { + var uniqueName = $"{compositeName}{DiscoveredToolNameSeparator}{suffix}"; + if (reservedNames.Add(uniqueName)) + { + return uniqueName; + } + } + } + + private static string SanitizeToolName(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "gateway_tool"; + } + + var builder = new System.Text.StringBuilder(value.Length); + foreach (var character in value) + { + builder.Append(char.IsLetterOrDigit(character) || character == '_' ? character : '_'); + } + + return builder.ToString(); + } } diff --git a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs new file mode 100644 index 0000000..e902b90 --- /dev/null +++ b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs @@ -0,0 +1,44 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ManagedCode.MCPGateway; + +public static class McpGatewayChatClientExtensions +{ + public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( + this IChatClient chatClient, + McpGatewayToolSet toolSet, + ILoggerFactory? loggerFactory = null, + IServiceProvider? functionInvocationServices = null, + McpGatewayAutoDiscoveryOptions? options = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentNullException.ThrowIfNull(toolSet); + + return new McpGatewayAutoDiscoveryChatClient( + chatClient, + toolSet, + loggerFactory, + functionInvocationServices, + options); + } + + public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( + this IChatClient chatClient, + IServiceProvider serviceProvider, + Action? configure = null) + { + ArgumentNullException.ThrowIfNull(chatClient); + ArgumentNullException.ThrowIfNull(serviceProvider); + + var options = new McpGatewayAutoDiscoveryOptions(); + configure?.Invoke(options); + + return chatClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider.GetRequiredService(), + serviceProvider.GetService(), + serviceProvider, + options); + } +} diff --git a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs new file mode 100644 index 0000000..739a297 --- /dev/null +++ b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.MCPGateway; + +public static class McpGatewayChatOptionsExtensions +{ + public static ChatOptions AddMcpGatewayTools( + this ChatOptions options, + McpGatewayToolSet toolSet, + string searchToolName = McpGatewayToolSet.DefaultSearchToolName, + string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(toolSet); + + options.Tools = toolSet.AddTools(options.Tools ?? new List(), searchToolName, invokeToolName); + return options; + } + + public static ChatOptions AddMcpGatewayTools( + this ChatOptions options, + IServiceProvider serviceProvider, + string searchToolName = McpGatewayToolSet.DefaultSearchToolName, + string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(serviceProvider); + + return options.AddMcpGatewayTools( + serviceProvider.GetRequiredService(), + searchToolName, + invokeToolName); + } + + [Obsolete("Use AddMcpGatewayTools(...) instead.")] + public static ChatOptions AddManagedCodeMcpGatewayTools( + this ChatOptions options, + McpGatewayToolSet toolSet, + string searchToolName = McpGatewayToolSet.DefaultSearchToolName, + string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) + => options.AddMcpGatewayTools(toolSet, searchToolName, invokeToolName); + + [Obsolete("Use AddMcpGatewayTools(...) instead.")] + public static ChatOptions AddManagedCodeMcpGatewayTools( + this ChatOptions options, + IServiceProvider serviceProvider, + string searchToolName = McpGatewayToolSet.DefaultSearchToolName, + string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) + => options.AddMcpGatewayTools(serviceProvider, searchToolName, invokeToolName); +} diff --git a/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs b/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs new file mode 100644 index 0000000..2d99069 --- /dev/null +++ b/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs @@ -0,0 +1,99 @@ +using ManagedCode.MCPGateway.Abstractions; + +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ManagedCode.MCPGateway.Tests; + +public sealed class McpGatewayAgentFrameworkIntegrationTests +{ + [TUnit.Core.Test] + public async Task ChatClientAgent_UsesAutoDiscoveryWithoutEmbeddings() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create( + GatewayIntegrationTestSupport.ConfigureFiftyToolCatalog); + var gateway = serviceProvider.GetRequiredService(); + var loggerFactory = serviceProvider.GetRequiredService(); + await gateway.BuildIndexAsync(); + + var modelClient = new TestChatClient(new TestChatClientOptions + { + Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: false) + }); + using var autoDiscoveryClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => options.MaxDiscoveredTools = 2); + + var agent = new ChatClientAgent( + autoDiscoveryClient, + instructions: "Use the gateway tools when the user asks for catalog actions.", + name: "gateway-agent", + description: "Agent integration test", + tools: [], + loggerFactory: loggerFactory, + services: serviceProvider); + + var response = await agent.RunAsync( + "Find the right tools as you go and execute them.", + session: null, + options: new ChatClientAgentRunOptions(new ChatOptions + { + AllowMultipleToolCalls = false + }), + cancellationToken: default); + + var registeredTools = await gateway.ListToolsAsync(); + + await Assert.That(registeredTools.Count).IsEqualTo(GatewayIntegrationTestSupport.CatalogToolCount); + await Assert.That(response.Text).IsEqualTo(GatewayIntegrationTestSupport.FinalAssistantResponse); + await GatewayIntegrationTestSupport.AssertAutoDiscoveryFlow(modelClient, "lexical"); + } + + [TUnit.Core.Test] + public async Task ChatClientAgent_UsesAutoDiscoveryWithEmbeddings() + { + var embeddingGenerator = GatewayIntegrationTestSupport.CreateAutoDiscoveryEmbeddingGenerator(); + + await using var serviceProvider = GatewayTestServiceProviderFactory.Create( + GatewayIntegrationTestSupport.ConfigureFiftyToolCatalog, + embeddingGenerator: embeddingGenerator); + var gateway = serviceProvider.GetRequiredService(); + var loggerFactory = serviceProvider.GetRequiredService(); + await gateway.BuildIndexAsync(); + + var modelClient = new TestChatClient(new TestChatClientOptions + { + Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: true) + }); + using var autoDiscoveryClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => options.MaxDiscoveredTools = 2); + + var agent = new ChatClientAgent( + autoDiscoveryClient, + instructions: "Use the gateway tools when the user asks for catalog actions.", + name: "gateway-agent", + description: "Agent integration test", + tools: [], + loggerFactory: loggerFactory, + services: serviceProvider); + + var response = await agent.RunAsync( + "Find the right tools as you go and execute them.", + session: null, + options: new ChatClientAgentRunOptions(new ChatOptions + { + AllowMultipleToolCalls = false + }), + cancellationToken: default); + + var registeredTools = await gateway.ListToolsAsync(); + + await Assert.That(registeredTools.Count).IsEqualTo(GatewayIntegrationTestSupport.CatalogToolCount); + await Assert.That(response.Text).IsEqualTo(GatewayIntegrationTestSupport.FinalAssistantResponse); + await Assert.That(embeddingGenerator.Calls.Count >= 3).IsTrue(); + await GatewayIntegrationTestSupport.AssertAutoDiscoveryFlow(modelClient, "vector"); + } +} diff --git a/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs b/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs new file mode 100644 index 0000000..abe76d0 --- /dev/null +++ b/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs @@ -0,0 +1,93 @@ +using ManagedCode.MCPGateway.Abstractions; + +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; + +namespace ManagedCode.MCPGateway.Tests; + +public sealed class McpGatewayChatClientIntegrationTests +{ + [TUnit.Core.Test] + public async Task ChatOptions_AddMcpGatewayTools_ResolvesToolSetAndAvoidsDuplicates() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create(static _ => { }); + + var options = new ChatOptions() + .AddMcpGatewayTools(serviceProvider) + .AddMcpGatewayTools(serviceProvider); + + await Assert.That(options.Tools).IsNotNull(); + await Assert.That(options.Tools!.Count).IsEqualTo(2); + await Assert.That(options.Tools.Select(static tool => tool.Name)).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName + ]); + } + + [TUnit.Core.Test] + public async Task AutoDiscoveryChatClient_ReplacesDiscoveredToolsWithoutEmbeddings() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create( + GatewayIntegrationTestSupport.ConfigureFiftyToolCatalog); + var gateway = serviceProvider.GetRequiredService(); + await gateway.BuildIndexAsync(); + + var modelClient = new TestChatClient(new TestChatClientOptions + { + Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: false) + }); + + using var chatClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => options.MaxDiscoveredTools = 2); + + var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Find the right tools as you go and execute them.")], + new ChatOptions + { + AllowMultipleToolCalls = false + }); + + var registeredTools = await gateway.ListToolsAsync(); + + await Assert.That(registeredTools.Count).IsEqualTo(GatewayIntegrationTestSupport.CatalogToolCount); + await Assert.That(response.Text).IsEqualTo(GatewayIntegrationTestSupport.FinalAssistantResponse); + await GatewayIntegrationTestSupport.AssertAutoDiscoveryFlow(modelClient, "lexical"); + } + + [TUnit.Core.Test] + public async Task AutoDiscoveryChatClient_ReplacesDiscoveredToolsWithEmbeddings() + { + var embeddingGenerator = GatewayIntegrationTestSupport.CreateAutoDiscoveryEmbeddingGenerator(); + + await using var serviceProvider = GatewayTestServiceProviderFactory.Create( + GatewayIntegrationTestSupport.ConfigureFiftyToolCatalog, + embeddingGenerator: embeddingGenerator); + var gateway = serviceProvider.GetRequiredService(); + await gateway.BuildIndexAsync(); + + var modelClient = new TestChatClient(new TestChatClientOptions + { + Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: true) + }); + + using var chatClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + serviceProvider, + options => options.MaxDiscoveredTools = 2); + + var response = await chatClient.GetResponseAsync( + [new ChatMessage(ChatRole.User, "Find the right tools as you go and execute them.")], + new ChatOptions + { + AllowMultipleToolCalls = false + }); + + var registeredTools = await gateway.ListToolsAsync(); + + await Assert.That(registeredTools.Count).IsEqualTo(GatewayIntegrationTestSupport.CatalogToolCount); + await Assert.That(response.Text).IsEqualTo(GatewayIntegrationTestSupport.FinalAssistantResponse); + await Assert.That(embeddingGenerator.Calls.Count >= 3).IsTrue(); + await GatewayIntegrationTestSupport.AssertAutoDiscoveryFlow(modelClient, "vector"); + } +} diff --git a/tests/ManagedCode.MCPGateway.Tests/ManagedCode.MCPGateway.Tests.csproj b/tests/ManagedCode.MCPGateway.Tests/ManagedCode.MCPGateway.Tests.csproj index 47260e7..2169170 100644 --- a/tests/ManagedCode.MCPGateway.Tests/ManagedCode.MCPGateway.Tests.csproj +++ b/tests/ManagedCode.MCPGateway.Tests/ManagedCode.MCPGateway.Tests.csproj @@ -10,6 +10,7 @@ + all diff --git a/tests/ManagedCode.MCPGateway.Tests/Search/McpGatewaySearchBuildTests.cs b/tests/ManagedCode.MCPGateway.Tests/Search/McpGatewaySearchBuildTests.cs index fd3b794..852d9b6 100644 --- a/tests/ManagedCode.MCPGateway.Tests/Search/McpGatewaySearchBuildTests.cs +++ b/tests/ManagedCode.MCPGateway.Tests/Search/McpGatewaySearchBuildTests.cs @@ -1,9 +1,9 @@ +using System.Reflection; using ManagedCode.MCPGateway.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Client; -using System.Reflection; namespace ManagedCode.MCPGateway.Tests; diff --git a/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs b/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs new file mode 100644 index 0000000..92e98ee --- /dev/null +++ b/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs @@ -0,0 +1,244 @@ +using System.Globalization; + +using Microsoft.Extensions.AI; + +namespace ManagedCode.MCPGateway.Tests; + +internal static class GatewayIntegrationTestSupport +{ + public const int CatalogToolCount = 50; + public const string WeatherToolName = "weather_forecast_specialist"; + public const string WeatherAirQualityToolName = "weather_air_quality_watch"; + public const string PortfolioToolName = "portfolio_status_specialist"; + public const string InvoiceToolName = "finance_invoice_watch"; + public const string WeatherToolId = $"local:{WeatherToolName}"; + public const string WeatherAirQualityToolId = $"local:{WeatherAirQualityToolName}"; + public const string PortfolioToolId = $"local:{PortfolioToolName}"; + public const string InvoiceToolId = $"local:{InvoiceToolName}"; + public const string WeatherInvokeQuery = "Kyiv"; + public const string PortfolioInvokeQuery = "ACME"; + public const string FinalAssistantResponse = "done:weather:Kyiv|portfolio:ACME"; + private const string WeatherLexicalQuery = "kyiv weather forecast"; + private const string WeatherSemanticQuery = "umbrella planning for kyiv"; + private const string PortfolioLexicalQuery = "portfolio market value exposure billing"; + private const string PortfolioSemanticQuery = "brokerage holdings snapshot"; + + public static void ConfigureFiftyToolCatalog(McpGatewayOptions options) + { + for (var index = 1; index <= CatalogToolCount; index++) + { + switch (index) + { + case 37: + options.AddTool( + "local", + TestFunctionFactory.CreateFunction( + (string query) => $"weather:{query}", + WeatherToolName, + "Get weather forecast, temperature, wind, and precipitation details for a city.")); + break; + case 38: + options.AddTool( + "local", + TestFunctionFactory.CreateFunction( + (string query) => $"air-quality:{query}", + WeatherAirQualityToolName, + "Check weather alerts, air quality, smoke exposure, and pollution levels for a city.")); + break; + case 41: + options.AddTool( + "local", + TestFunctionFactory.CreateFunction( + (string query) => $"portfolio:{query}", + PortfolioToolName, + "Summarize portfolio holdings, market value, exposure, and unrealized profit for a brokerage account.")); + break; + case 42: + options.AddTool( + "local", + TestFunctionFactory.CreateFunction( + (string query) => $"invoice:{query}", + InvoiceToolName, + "Review finance invoices, billing exposure, receivables, and payment holds for a customer account.")); + break; + default: + var toolIndex = index.ToString("D2", CultureInfo.InvariantCulture); + var toolName = $"catalog_tool_{toolIndex}"; + options.AddTool( + "local", + TestFunctionFactory.CreateFunction( + (string query) => $"{toolName}:{query}", + toolName, + $"Handle archive lookup workflow number {toolIndex} for genealogy records.")); + break; + } + } + } + + public static TestEmbeddingGenerator CreateAutoDiscoveryEmbeddingGenerator() + => new(new TestEmbeddingGeneratorOptions + { + Metadata = new EmbeddingGeneratorMetadata( + "ManagedCode.MCPGateway.Tests", + new Uri("https://example.test"), + "gateway-autodiscovery", + 4), + CreateVector = CreateSemanticVector + }); + + public static IReadOnlyList CreateAutoDiscoveryScenarios(bool useSemanticQueries) + { + var firstSearchQuery = useSemanticQueries ? WeatherSemanticQuery : WeatherLexicalQuery; + var secondSearchQuery = useSemanticQueries ? PortfolioSemanticQuery : PortfolioLexicalQuery; + + return + [ + new( + "search weather tools", + invocation => invocation.CountFunctionResults(McpGatewayToolSet.DefaultSearchToolName) == 0, + _ => TestChatClientScenario.FunctionCall( + callId: "search-weather", + functionName: McpGatewayToolSet.DefaultSearchToolName, + arguments: new Dictionary + { + ["query"] = firstSearchQuery, + ["maxResults"] = 2 + })), + new( + "invoke discovered weather tool", + invocation => invocation.CountFunctionResults(McpGatewayToolSet.DefaultSearchToolName) == 1 && + invocation.CountFunctionResults(WeatherToolName) == 0, + _ => TestChatClientScenario.FunctionCall( + callId: "invoke-weather", + functionName: WeatherToolName, + arguments: new Dictionary + { + ["query"] = WeatherInvokeQuery + })), + new( + "search finance tools", + invocation => invocation.CountFunctionResults(WeatherToolName) == 1 && + invocation.CountFunctionResults(McpGatewayToolSet.DefaultSearchToolName) == 1, + _ => TestChatClientScenario.FunctionCall( + callId: "search-portfolio", + functionName: McpGatewayToolSet.DefaultSearchToolName, + arguments: new Dictionary + { + ["query"] = secondSearchQuery, + ["maxResults"] = 2 + })), + new( + "invoke discovered portfolio tool", + invocation => invocation.CountFunctionResults(McpGatewayToolSet.DefaultSearchToolName) == 2 && + invocation.CountFunctionResults(PortfolioToolName) == 0, + _ => TestChatClientScenario.FunctionCall( + callId: "invoke-portfolio", + functionName: PortfolioToolName, + arguments: new Dictionary + { + ["query"] = PortfolioInvokeQuery + })), + new( + "return final text", + invocation => invocation.CountFunctionResults(PortfolioToolName) == 1, + invocation => + { + var weatherResult = invocation.ReadLatestFunctionResult(WeatherToolName) + ?? throw new InvalidOperationException("Weather result is missing."); + var portfolioResult = invocation.ReadLatestFunctionResult(PortfolioToolName) + ?? throw new InvalidOperationException("Portfolio result is missing."); + + return TestChatClientScenario.Text($"done:{weatherResult.Output}|{portfolioResult.Output}"); + }) + ]; + } + + public static async Task AssertAutoDiscoveryFlow( + TestChatClient chatClient, + string expectedRankingMode) + { + await Assert.That(chatClient.Invocations.Count).IsEqualTo(5); + await Assert.That(chatClient.Invocations[0].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName + ]); + await Assert.That(chatClient.Invocations[1].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName, + WeatherToolName, + WeatherAirQualityToolName + ]); + await Assert.That(chatClient.Invocations[2].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName, + WeatherToolName, + WeatherAirQualityToolName + ]); + await Assert.That(chatClient.Invocations[3].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName, + PortfolioToolName, + InvoiceToolName + ]); + await Assert.That(chatClient.Invocations[4].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName, + PortfolioToolName, + InvoiceToolName + ]); + await Assert.That(chatClient.Invocations[3].ToolNames.Any(static name => + string.Equals(name, WeatherToolName, StringComparison.OrdinalIgnoreCase))) + .IsFalse(); + + var firstSearchResult = chatClient.Invocations[1].ReadLatestFunctionResult( + McpGatewayToolSet.DefaultSearchToolName) + ?? throw new InvalidOperationException("First search result is missing."); + var secondSearchResult = chatClient.Invocations[3].ReadLatestFunctionResult( + McpGatewayToolSet.DefaultSearchToolName) + ?? throw new InvalidOperationException("Second search result is missing."); + + await Assert.That(firstSearchResult.RankingMode).IsEqualTo(expectedRankingMode); + await Assert.That(firstSearchResult.Matches[0].ToolId).IsEqualTo(WeatherToolId); + await Assert.That(firstSearchResult.Matches[1].ToolId).IsEqualTo(WeatherAirQualityToolId); + await Assert.That(secondSearchResult.RankingMode).IsEqualTo(expectedRankingMode); + await Assert.That(secondSearchResult.Matches[0].ToolId).IsEqualTo(PortfolioToolId); + await Assert.That(secondSearchResult.Matches[1].ToolId).IsEqualTo(InvoiceToolId); + + foreach (var invocation in chatClient.Invocations) + { + await Assert.That(invocation.FindLatestFunctionResult(McpGatewayToolSet.DefaultInvokeToolName)).IsNull(); + } + } + + private static float[] CreateSemanticVector(string value) + { + var normalized = value.ToLowerInvariant(); + var vector = new float[4]; + + vector[0] = Score(normalized, "weather", "forecast", "temperature", "umbrella", "rain", "wind", "precipitation"); + vector[1] = Score(normalized, "air quality", "pollution", "smoke", "aqi", "alerts"); + vector[2] = Score(normalized, "portfolio", "brokerage", "holdings", "exposure", "market value", "profit", "snapshot"); + vector[3] = Score(normalized, "invoice", "billing", "receivables", "payment", "collections"); + + return vector; + } + + private static float Score(string normalized, params string[] keywords) + { + var score = 0f; + foreach (var keyword in keywords) + { + if (normalized.Contains(keyword, StringComparison.Ordinal)) + { + score += 1f; + } + } + + return score; + } +} diff --git a/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestChatClient.cs b/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestChatClient.cs index 84fe9e2..dfafc07 100644 --- a/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestChatClient.cs +++ b/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestChatClient.cs @@ -1,3 +1,6 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + using Microsoft.Extensions.AI; namespace ManagedCode.MCPGateway.Tests; @@ -8,6 +11,8 @@ internal sealed class TestChatClient(TestChatClientOptions? options = null) : IC public List Calls { get; } = []; + public List Invocations { get; } = []; + public Task GetResponseAsync( IEnumerable messages, ChatOptions? options = null, @@ -15,7 +20,8 @@ public Task GetResponseAsync( { cancellationToken.ThrowIfCancellationRequested(); - var query = messages.LastOrDefault(static message => message.Role == ChatRole.User)?.Text ?? string.Empty; + var messageList = messages as IReadOnlyList ?? messages.ToList(); + var query = messageList.LastOrDefault(static message => message.Role == ChatRole.User)?.Text ?? string.Empty; Calls.Add(query); if (_options.ThrowOnInput?.Invoke(query) == true) @@ -23,8 +29,25 @@ public Task GetResponseAsync( throw new InvalidOperationException("Query normalization failed for a test input."); } + var invocation = new TestChatClientInvocation(messageList, options, Invocations.Count + 1); + Invocations.Add(invocation); + + foreach (var scenario in _options.Scenarios) + { + if (scenario.When(invocation)) + { + return Task.FromResult(scenario.Respond(invocation)); + } + } + + if (_options.Scenarios.Count > 0) + { + throw new InvalidOperationException( + $"No test chat scenario matched invocation #{invocation.InvocationIndex} for '{query}'."); + } + var rewrittenQuery = _options.RewriteQuery?.Invoke(query) ?? query; - return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, rewrittenQuery))); + return Task.FromResult(TestChatClientScenario.Text(rewrittenQuery)); } public async IAsyncEnumerable GetStreamingResponseAsync( @@ -32,8 +55,13 @@ public async IAsyncEnumerable GetStreamingResponseAsync( ChatOptions? options = null, [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) { - _ = await GetResponseAsync(messages, options, cancellationToken); - yield break; + var response = await GetResponseAsync(messages, options, cancellationToken); + + foreach (var update in response.ToChatResponseUpdates()) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return update; + } } public object? GetService(Type serviceType, object? serviceKey = null) @@ -52,4 +80,228 @@ internal sealed class TestChatClientOptions public Func? RewriteQuery { get; init; } public Func? ThrowOnInput { get; init; } + + public IReadOnlyList Scenarios { get; init; } = []; +} + +internal sealed class TestChatClientInvocation +{ + private readonly Dictionary _functionNamesByCallId = new(StringComparer.OrdinalIgnoreCase); + + public TestChatClientInvocation( + IReadOnlyList messages, + ChatOptions? options, + int invocationIndex) + { + InvocationIndex = invocationIndex; + Messages = messages; + Options = options; + UserText = messages.LastOrDefault(static message => message.Role == ChatRole.User)?.Text ?? string.Empty; + ToolNames = options?.Tools?.Select(static tool => tool.Name).ToArray() ?? []; + FunctionCalls = ExtractFunctionCalls(messages); + FunctionResults = ExtractFunctionResults(messages); + } + + public int InvocationIndex { get; } + + public IReadOnlyList Messages { get; } + + public ChatOptions? Options { get; } + + public string UserText { get; } + + public IReadOnlyList ToolNames { get; } + + public IReadOnlyList FunctionCalls { get; } + + public IReadOnlyList FunctionResults { get; } + + public FunctionResultContent? FindLatestFunctionResult(string functionName) + { + ArgumentNullException.ThrowIfNull(functionName); + + for (var index = FunctionResults.Count - 1; index >= 0; index--) + { + var result = FunctionResults[index]; + if (_functionNamesByCallId.TryGetValue(result.CallId, out var matchedFunctionName) + && string.Equals(matchedFunctionName, functionName, StringComparison.OrdinalIgnoreCase)) + { + return result; + } + } + + return null; + } + + public int CountFunctionResults(string functionName) + { + ArgumentNullException.ThrowIfNull(functionName); + + var count = 0; + for (var index = 0; index < FunctionResults.Count; index++) + { + var result = FunctionResults[index]; + if (_functionNamesByCallId.TryGetValue(result.CallId, out var matchedFunctionName) && + string.Equals(matchedFunctionName, functionName, StringComparison.OrdinalIgnoreCase)) + { + count++; + } + } + + return count; + } + + public T? ReadLatestFunctionResult(string functionName) + { + var functionResult = FindLatestFunctionResult(functionName); + if (functionResult is null) + { + return default; + } + + return TestChatClientScenario.ConvertResult(functionResult.Result); + } + + public JsonElement? ReadLatestFunctionResultElement(string functionName) + { + var functionResult = FindLatestFunctionResult(functionName); + if (functionResult is null || functionResult.Result is null) + { + return null; + } + + return functionResult.Result switch + { + JsonElement jsonElement => jsonElement, + _ => JsonSerializer.SerializeToElement(functionResult.Result, TestChatClientScenario.JsonOptions) + }; + } + + private static IReadOnlyList ExtractFunctionCalls(IReadOnlyList messages) + { + var functionCalls = new List(); + + foreach (var message in messages) + { + foreach (var content in message.Contents) + { + if (content is FunctionCallContent functionCall) + { + functionCalls.Add(functionCall); + } + } + } + + return functionCalls; + } + + private IReadOnlyList ExtractFunctionResults(IReadOnlyList messages) + { + var functionResults = new List(); + + foreach (var message in messages) + { + foreach (var content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCall: + _functionNamesByCallId[functionCall.CallId] = functionCall.Name; + break; + case FunctionResultContent functionResult: + functionResults.Add(functionResult); + break; + } + } + } + + return functionResults; + } +} + +internal sealed class TestChatClientScenario( + string name, + Func when, + Func respond) +{ + internal static readonly JsonSerializerOptions JsonOptions = CreateJsonOptions(); + + public string Name { get; } = name; + + public Func When { get; } = when; + + public Func Respond { get; } = respond; + + public static ChatResponse Text(string text) + => new(new ChatMessage(ChatRole.Assistant, text)); + + public static ChatResponse FunctionCall( + string callId, + string functionName, + IReadOnlyDictionary? arguments = null) + => FunctionCalls(new TestChatFunctionCall(callId, functionName, arguments)); + + public static ChatResponse FunctionCalls(params TestChatFunctionCall[] functionCalls) + { + ArgumentNullException.ThrowIfNull(functionCalls); + + var contents = new List(functionCalls.Length); + foreach (var functionCall in functionCalls) + { + contents.Add(new FunctionCallContent(functionCall.CallId, functionCall.FunctionName, functionCall.ToArgumentsDictionary())); + } + + return new ChatResponse(new ChatMessage(ChatRole.Assistant, contents)); + } + + public static T ConvertResult(object? result) + { + if (result is null) + { + return default!; + } + + if (result is T typedResult) + { + return typedResult; + } + + return result switch + { + JsonElement jsonElement => jsonElement.Deserialize(JsonOptions)!, + _ => JsonSerializer.Deserialize(JsonSerializer.Serialize(result, JsonOptions), JsonOptions)! + }; + } + + private static JsonSerializerOptions CreateJsonOptions() + { + var options = new JsonSerializerOptions(JsonSerializerDefaults.Web); + options.Converters.Add(new JsonStringEnumConverter()); + return options; + } +} + +internal sealed record TestChatFunctionCall( + string CallId, + string FunctionName, + IReadOnlyDictionary? Arguments = null) +{ + public Dictionary ToArgumentsDictionary() + { + var mappedArguments = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (Arguments is null) + { + return mappedArguments; + } + + foreach (var (name, value) in Arguments) + { + if (value is not null) + { + mappedArguments[name] = value; + } + } + + return mappedArguments; + } } diff --git a/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestEmbeddingGenerator.cs b/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestEmbeddingGenerator.cs index 0e4e27c..910642e 100644 --- a/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestEmbeddingGenerator.cs +++ b/tests/ManagedCode.MCPGateway.Tests/TestSupport/TestEmbeddingGenerator.cs @@ -95,6 +95,11 @@ public void Dispose() private Embedding CreateEmbedding(string value) { + if (_options.CreateVector is not null) + { + return new Embedding(_options.CreateVector(value)); + } + if (_options.ReturnZeroVectorOnInput?.Invoke(value) == true) { return new Embedding(new float[Vocabulary.Length]); @@ -123,5 +128,7 @@ internal sealed class TestEmbeddingGeneratorOptions public Func? ReturnZeroVectorOnInput { get; init; } + public Func? CreateVector { get; init; } + public bool ReturnMismatchedBatchCount { get; init; } } From 3dd99386e718c9eebc74b1de8955055542c68758 Mon Sep 17 00:00:00 2001 From: ksemenenko Date: Sun, 8 Mar 2026 10:24:45 +0100 Subject: [PATCH 2/2] Address PR feedback on auto-discovery APIs --- README.md | 4 +- ...able-chat-client-and-agent-tool-modules.md | 4 +- docs/Architecture/Overview.md | 4 +- .../McpGatewayAutoDiscoveryChatClient.cs | 11 +++- .../McpGatewayToolSet.cs | 12 +++- .../McpGatewayChatClientExtensions.cs | 6 +- .../McpGatewayChatOptionsExtensions.cs | 16 ----- ...cpGatewayAgentFrameworkIntegrationTests.cs | 4 +- .../McpGatewayChatClientIntegrationTests.cs | 58 ++++++++++++++++++- .../MetaTools/McpGatewayMetaToolTests.cs | 24 ++++++++ .../GatewayIntegrationTestSupport.cs | 3 - 11 files changed, 111 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index b39f48d..b47ed4d 100644 --- a/README.md +++ b/README.md @@ -278,7 +278,7 @@ using Microsoft.Extensions.DependencyInjection; await using var serviceProvider = services.BuildServiceProvider(); var innerChatClient = serviceProvider.GetRequiredService(); -using var chatClient = innerChatClient.UseManagedCodeMcpGatewayAutoDiscovery( +using var chatClient = innerChatClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => { @@ -321,7 +321,7 @@ await using var serviceProvider = services.BuildServiceProvider(); var innerChatClient = serviceProvider.GetRequiredService(); var loggerFactory = serviceProvider.GetRequiredService(); -using var chatClient = innerChatClient.UseManagedCodeMcpGatewayAutoDiscovery( +using var chatClient = innerChatClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => { diff --git a/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md b/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md index b813a9d..c54a0e0 100644 --- a/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md +++ b/docs/ADR/ADR-0003-reusable-chat-client-and-agent-tool-modules.md @@ -26,7 +26,7 @@ At the same time, the repository still wants to keep the core package generic, l - `McpGatewayToolSet.AddTools(...)` composes those tools into an existing `IList` without duplicating names - `ChatOptions.AddMcpGatewayTools(...)` attaches the same tools to chat-client requests - `McpGatewayToolSet.CreateDiscoveredTools(...)` projects the latest search matches as direct proxy tools -- `McpGatewayAutoDiscoveryChatClient` and `UseManagedCodeMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper for both plain `IChatClient` and Agent Framework hosts +- `McpGatewayAutoDiscoveryChatClient` and `UseMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper for both plain `IChatClient` and Agent Framework hosts The recommended host flow is: @@ -127,7 +127,7 @@ Mitigations: Rollout: 1. Keep `McpGatewayToolSet` as the reusable module entry point. -2. Add `McpGatewayAutoDiscoveryChatClient` and `UseManagedCodeMcpGatewayAutoDiscovery(...)` as the recommended host wrapper. +2. Add `McpGatewayAutoDiscoveryChatClient` and `UseMcpGatewayAutoDiscovery(...)` as the recommended host wrapper. 3. Document both chat-client and agent composition examples in `README.md`. 4. Keep architecture docs aligned with the generic `AITool`-module approach. diff --git a/docs/Architecture/Overview.md b/docs/Architecture/Overview.md index 3984af6..349a42c 100644 --- a/docs/Architecture/Overview.md +++ b/docs/Architecture/Overview.md @@ -26,7 +26,7 @@ Out of scope: `McpGateway` stays a thin facade over `McpGatewayRuntime`, which reads immutable catalog snapshots, coordinates vector or tokenizer-backed search, optionally rewrites queries through a keyed `IChatClient`, and invokes local or MCP tools. Optional startup warmup is available through a service-provider extension or hosted background service without changing the lazy default. -The package also keeps chat-client and agent integration generic: `McpGatewayToolSet` is the source of reusable `AITool` meta-tools and discovered proxy tools, `ChatOptions.AddMcpGatewayTools(...)` remains the low-level bridge, and `McpGatewayAutoDiscoveryChatClient` plus `UseManagedCodeMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper that starts with two meta-tools and replaces the discovered proxy set on each new search result without introducing a hard Agent Framework dependency into the core package. +The package also keeps chat-client and agent integration generic: `McpGatewayToolSet` is the source of reusable `AITool` meta-tools and discovered proxy tools, `ChatOptions.AddMcpGatewayTools(...)` remains the low-level bridge, and `McpGatewayAutoDiscoveryChatClient` plus `UseMcpGatewayAutoDiscovery(...)` provide the recommended staged host wrapper that starts with two meta-tools and replaces the discovered proxy set on each new search result without introducing a hard Agent Framework dependency into the core package. ## System And Module Map @@ -63,7 +63,7 @@ flowchart LR ToolSet --> ToolList["IList composition"] ToolSet --> DiscoveredTools["CreateDiscoveredTools(...)"] ChatOptions["ChatOptions.AddMcpGatewayTools(...)"] --> ToolSet - AutoDiscovery["McpGatewayAutoDiscoveryChatClient / UseManagedCodeMcpGatewayAutoDiscovery(...)"] --> ToolSet + AutoDiscovery["McpGatewayAutoDiscoveryChatClient / UseMcpGatewayAutoDiscovery(...)"] --> ToolSet AutoDiscovery --> ChatClient Warmup["McpGatewayServiceProviderExtensions / McpGatewayIndexWarmupService"] --> IMcpGateway McpGateway --> Runtime["McpGatewayRuntime"] diff --git a/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs b/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs index 2a72327..4d23696 100644 --- a/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs +++ b/src/ManagedCode.MCPGateway/McpGatewayAutoDiscoveryChatClient.cs @@ -230,12 +230,19 @@ when functionNamesByCallId.TryGetValue(functionResult.CallId, out var functionNa } var serialized = McpGatewayJsonSerializer.TrySerializeToElement(result); - if (serialized is not JsonElement jsonElement) + if (serialized is not JsonElement { ValueKind: JsonValueKind.Object } jsonElement) { return null; } - return jsonElement.Deserialize(McpGatewayJsonSerializer.Options); + try + { + return jsonElement.Deserialize(McpGatewayJsonSerializer.Options); + } + catch (JsonException) + { + return null; + } } } diff --git a/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs b/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs index 4564165..c56827c 100644 --- a/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs +++ b/src/ManagedCode.MCPGateway/McpGatewayToolSet.cs @@ -50,7 +50,7 @@ public IList AddTools( { ArgumentNullException.ThrowIfNull(tools); - var targetTools = tools.IsReadOnly ? new List(tools) : tools; + var targetTools = new List(tools); var toolNames = new HashSet(StringComparer.OrdinalIgnoreCase); foreach (var tool in targetTools) @@ -249,6 +249,16 @@ private static string SanitizeToolName(string value) builder.Append(char.IsLetterOrDigit(character) || character == '_' ? character : '_'); } + if (builder.Length == 0) + { + return "gateway_tool"; + } + + if (!char.IsLetter(builder[0]) && builder[0] != '_') + { + builder.Insert(0, "t_"); + } + return builder.ToString(); } } diff --git a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs index e902b90..ff64545 100644 --- a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs +++ b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatClientExtensions.cs @@ -6,7 +6,7 @@ namespace ManagedCode.MCPGateway; public static class McpGatewayChatClientExtensions { - public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( + public static IChatClient UseMcpGatewayAutoDiscovery( this IChatClient chatClient, McpGatewayToolSet toolSet, ILoggerFactory? loggerFactory = null, @@ -24,7 +24,7 @@ public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( options); } - public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( + public static IChatClient UseMcpGatewayAutoDiscovery( this IChatClient chatClient, IServiceProvider serviceProvider, Action? configure = null) @@ -35,7 +35,7 @@ public static IChatClient UseManagedCodeMcpGatewayAutoDiscovery( var options = new McpGatewayAutoDiscoveryOptions(); configure?.Invoke(options); - return chatClient.UseManagedCodeMcpGatewayAutoDiscovery( + return chatClient.UseMcpGatewayAutoDiscovery( serviceProvider.GetRequiredService(), serviceProvider.GetService(), serviceProvider, diff --git a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs index 739a297..9e525e3 100644 --- a/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs +++ b/src/ManagedCode.MCPGateway/Registration/McpGatewayChatOptionsExtensions.cs @@ -32,20 +32,4 @@ public static ChatOptions AddMcpGatewayTools( searchToolName, invokeToolName); } - - [Obsolete("Use AddMcpGatewayTools(...) instead.")] - public static ChatOptions AddManagedCodeMcpGatewayTools( - this ChatOptions options, - McpGatewayToolSet toolSet, - string searchToolName = McpGatewayToolSet.DefaultSearchToolName, - string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) - => options.AddMcpGatewayTools(toolSet, searchToolName, invokeToolName); - - [Obsolete("Use AddMcpGatewayTools(...) instead.")] - public static ChatOptions AddManagedCodeMcpGatewayTools( - this ChatOptions options, - IServiceProvider serviceProvider, - string searchToolName = McpGatewayToolSet.DefaultSearchToolName, - string invokeToolName = McpGatewayToolSet.DefaultInvokeToolName) - => options.AddMcpGatewayTools(serviceProvider, searchToolName, invokeToolName); } diff --git a/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs b/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs index 2d99069..44b8b90 100644 --- a/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs +++ b/tests/ManagedCode.MCPGateway.Tests/Agents/McpGatewayAgentFrameworkIntegrationTests.cs @@ -22,7 +22,7 @@ public async Task ChatClientAgent_UsesAutoDiscoveryWithoutEmbeddings() { Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: false) }); - using var autoDiscoveryClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + using var autoDiscoveryClient = modelClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => options.MaxDiscoveredTools = 2); @@ -67,7 +67,7 @@ public async Task ChatClientAgent_UsesAutoDiscoveryWithEmbeddings() { Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: true) }); - using var autoDiscoveryClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + using var autoDiscoveryClient = modelClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => options.MaxDiscoveredTools = 2); diff --git a/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs b/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs index abe76d0..16ebbce 100644 --- a/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs +++ b/tests/ManagedCode.MCPGateway.Tests/ChatClient/McpGatewayChatClientIntegrationTests.cs @@ -2,6 +2,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; namespace ManagedCode.MCPGateway.Tests; @@ -25,6 +26,59 @@ await Assert.That(options.Tools.Select(static tool => tool.Name)).IsEquivalentTo ]); } + [TUnit.Core.Test] + public async Task ToolSet_AddTools_ReturnsNewListWithoutMutatingInput() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create(static _ => { }); + var toolSet = serviceProvider.GetRequiredService(); + var existingTools = new List + { + TestFunctionFactory.CreateFunction( + static () => "existing", + "existing_tool", + "Existing tool.") + }; + + var composedTools = toolSet.AddTools(existingTools); + + await Assert.That(existingTools.Count).IsEqualTo(1); + await Assert.That(composedTools.Count).IsEqualTo(3); + await Assert.That(ReferenceEquals(existingTools, composedTools)).IsFalse(); + } + + [TUnit.Core.Test] + public async Task AutoDiscoveryChatClient_IgnoresPrimitiveSearchResultPayloads() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create(static _ => { }); + var modelClient = new TestChatClient(new TestChatClientOptions + { + Scenarios = + [ + new TestChatClientScenario( + "return text", + static _ => true, + static _ => TestChatClientScenario.Text("ok")) + ] + }); + + using var chatClient = modelClient.UseMcpGatewayAutoDiscovery(serviceProvider); + + var response = await chatClient.GetResponseAsync( + [ + new ChatMessage(ChatRole.User, "Find the right tools."), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("search-1", McpGatewayToolSet.DefaultSearchToolName, new Dictionary())]), + new ChatMessage(ChatRole.Assistant, [new FunctionResultContent("search-1", JsonSerializer.SerializeToElement("not-an-object"))]) + ]); + + await Assert.That(response.Text).IsEqualTo("ok"); + await Assert.That(modelClient.Invocations.Count).IsEqualTo(1); + await Assert.That(modelClient.Invocations[0].ToolNames).IsEquivalentTo( + [ + McpGatewayToolSet.DefaultSearchToolName, + McpGatewayToolSet.DefaultInvokeToolName + ]); + } + [TUnit.Core.Test] public async Task AutoDiscoveryChatClient_ReplacesDiscoveredToolsWithoutEmbeddings() { @@ -38,7 +92,7 @@ public async Task AutoDiscoveryChatClient_ReplacesDiscoveredToolsWithoutEmbeddin Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: false) }); - using var chatClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + using var chatClient = modelClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => options.MaxDiscoveredTools = 2); @@ -72,7 +126,7 @@ public async Task AutoDiscoveryChatClient_ReplacesDiscoveredToolsWithEmbeddings( Scenarios = GatewayIntegrationTestSupport.CreateAutoDiscoveryScenarios(useSemanticQueries: true) }); - using var chatClient = modelClient.UseManagedCodeMcpGatewayAutoDiscovery( + using var chatClient = modelClient.UseMcpGatewayAutoDiscovery( serviceProvider, options => options.MaxDiscoveredTools = 2); diff --git a/tests/ManagedCode.MCPGateway.Tests/MetaTools/McpGatewayMetaToolTests.cs b/tests/ManagedCode.MCPGateway.Tests/MetaTools/McpGatewayMetaToolTests.cs index 808368c..3e7eb0f 100644 --- a/tests/ManagedCode.MCPGateway.Tests/MetaTools/McpGatewayMetaToolTests.cs +++ b/tests/ManagedCode.MCPGateway.Tests/MetaTools/McpGatewayMetaToolTests.cs @@ -69,6 +69,30 @@ public async Task CreateMetaTools_InvokeToolSupportsContextSummary() await Assert.That(GetJsonProperty(invokeResult, "output").GetString()).IsEqualTo("open github|user is on repository settings page"); } + [TUnit.Core.Test] + public async Task CreateDiscoveredTools_PrefixesNamesThatStartWithDigits() + { + await using var serviceProvider = GatewayTestServiceProviderFactory.Create(static _ => { }); + var toolSet = serviceProvider.GetRequiredService(); + + var discoveredTools = toolSet.CreateDiscoveredTools( + [ + new McpGatewaySearchMatch( + ToolId: "local:123-tool", + SourceId: "9-source", + SourceKind: McpGatewaySourceKind.Local, + ToolName: "123 tool", + DisplayName: null, + Description: "Example tool.", + RequiredArguments: [], + InputSchemaJson: null, + Score: 0.9d) + ]); + + await Assert.That(discoveredTools.Count).IsEqualTo(1); + await Assert.That(discoveredTools[0].Name).IsEqualTo("t_123_tool"); + } + private static AIFunction GetFunction(IReadOnlyList tools, string toolName) => (tools.Single(tool => tool.Name == toolName) as AIFunction) ?? throw new InvalidOperationException($"Tool '{toolName}' is not an AIFunction."); diff --git a/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs b/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs index 92e98ee..be06884 100644 --- a/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs +++ b/tests/ManagedCode.MCPGateway.Tests/TestSupport/GatewayIntegrationTestSupport.cs @@ -191,9 +191,6 @@ await Assert.That(chatClient.Invocations[4].ToolNames).IsEquivalentTo( PortfolioToolName, InvoiceToolName ]); - await Assert.That(chatClient.Invocations[3].ToolNames.Any(static name => - string.Equals(name, WeatherToolName, StringComparison.OrdinalIgnoreCase))) - .IsFalse(); var firstSearchResult = chatClient.Invocations[1].ReadLatestFunctionResult( McpGatewayToolSet.DefaultSearchToolName)