From 6ebffbb8b2f44b76f3d7202fd2fbfde7dd9e9605 Mon Sep 17 00:00:00 2001 From: George Pickett <297992784+georgeatparallel@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:44:35 +0000 Subject: [PATCH] Add opt-in Parallel Search MCP configuration example --- docs/index.rst | 6 + docs/mcp/parallel-search.json | 24 +++ docs/mcp/parallel-search.md | 57 ++++++ .../BotSharp.Core.UnitTests.csproj | 4 + .../MCP/ParallelSearchExampleTests.cs | 186 ++++++++++++++++++ 5 files changed, 277 insertions(+) create mode 100644 docs/mcp/parallel-search.json create mode 100644 docs/mcp/parallel-search.md create mode 100644 tests/BotSharp.Core.UnitTests/MCP/ParallelSearchExampleTests.cs diff --git a/docs/index.rst b/docs/index.rst index d9dac9039..c82e3b2d4 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -96,6 +96,12 @@ The main documentation for the site is organized into the following sections: llm/few-shot-learning llm/provider +.. toctree:: + :maxdepth: 2 + :caption: MCP + + mcp/parallel-search + .. _llamasharp: .. toctree:: diff --git a/docs/mcp/parallel-search.json b/docs/mcp/parallel-search.json new file mode 100644 index 000000000..852cea472 --- /dev/null +++ b/docs/mcp/parallel-search.json @@ -0,0 +1,24 @@ +{ + "MCP": { + "Enabled": true, + "McpClientOptions": { + "ClientInfo": { + "Name": "BotSharp", + "Version": "5.2.0" + } + }, + "McpServerConfigs": [ + { + "Id": "parallel-search", + "Name": "Parallel Search", + "Enabled": true, + "HttpConfig": { + "EndPoint": "https://search.parallel.ai/mcp", + "AdditionalHeaders": { + "User-Agent": "BotSharp/5.2.0" + } + } + } + ] + } +} diff --git a/docs/mcp/parallel-search.md b/docs/mcp/parallel-search.md new file mode 100644 index 000000000..5a0744fa0 --- /dev/null +++ b/docs/mcp/parallel-search.md @@ -0,0 +1,57 @@ +# Parallel Search MCP + +BotSharp can use the free, keyless [Parallel Search MCP](https://docs.parallel.ai/integrations/mcp/search-mcp) service for web search and page extraction. This example uses the existing HTTP MCP integration. It does not require a Parallel API key or a new plugin. + +## Configure a server + +The [example configuration](parallel-search.json) uses BotSharp's `MCP` section, `HttpConfig`, and the Streamable HTTP endpoint `https://search.parallel.ai/mcp`. Add its server object to `MCP.McpServerConfigs` in your host's configuration. Keep any existing server entries and client settings. The example's `User-Agent` identifies the caller as BotSharp; update its version when using another BotSharp release. + +For a host that registers MCP directly, load your configuration and call: + +```csharp +services.AddBotSharpMCP(configuration); +``` + +WebStarter already calls `AddBotSharpMCP` in `Program.cs`. Keep `MCP.Enabled` and this server's `Enabled` set to `true` to use it. Setting the server's `Enabled` to `false` prevents connections to that server. The repository's WebStarter configuration and existing provider selections are unchanged by this example. + +## Select tools for an agent + +Use the MCP server list in BotSharp's UI to select **Parallel Search**, then enable `web_search` and `web_fetch` for the agent. The server list discovers the tools through `IMcpService.GetServerConfigsAsync()`. The corresponding agent `mcp_tools` entry is: + +```json +{ + "name": "Parallel Search", + "server_id": "parallel-search", + "disabled": false, + "functions": [ + { "name": "web_search" }, + { "name": "web_fetch" } + ] +} +``` + +BotSharp's MCP agent hook adds the selected tools to the agent's secondary functions during a conversation. Its executor factory routes calls to `McpToolExecutor`, which returns the server's text content to the conversation. + +Ask the agent to find Microsoft's official introduction to .NET and explain what .NET is using that source. `web_search` takes an `objective` and a `search_queries` array. Its excerpts can often answer the question directly. Use `web_fetch`, with a `urls` array and optional `objective`, when a specific page or exact wording is needed. Both tools also support a shared `session_id` for the conversation. Your agent still needs its existing LLM configuration; keyless search does not supply an LLM. + +## Run the example validation + +With the .NET 8 SDK, from the repository root: + +```sh +dotnet test tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj \ + --filter FullyQualifiedName~ParallelSearchExampleTests \ + --logger "console;verbosity=detailed" +``` + +By default this runs a local HTTP fixture with no external requests. To use the public service explicitly, on a POSIX shell: + +```sh +BOTSHARP_PARALLEL_LIVE=1 dotnet test tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj \ + --filter FullyQualifiedName~ParallelSearchExampleTests \ + --logger "console;verbosity=detailed" +``` + +For PowerShell, set `$env:BOTSHARP_PARALLEL_LIVE = "1"` before the same test command, then remove it with `Remove-Item Env:BOTSHARP_PARALLEL_LIVE` when done. Live validation requires network access and is subject to the service's availability and usage limits. + +The test loads the example JSON through `AddBotSharpMCP`, discovers tools through `IMcpService`, loads them through the agent hook, and invokes search and fetch through the executor factory. It prints the returned source content and checks that discovery and tool calls identify BotSharp without authentication headers. It uses test conversation state and no LLM, saved settings, environment configuration provider, or credential store. It also checks that disabling the agent's MCP entry removes the tools and disabling the server prevents a connection. diff --git a/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj b/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj index 46f2f5e5b..753abee69 100644 --- a/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj +++ b/tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj @@ -26,6 +26,10 @@ + + + + diff --git a/tests/BotSharp.Core.UnitTests/MCP/ParallelSearchExampleTests.cs b/tests/BotSharp.Core.UnitTests/MCP/ParallelSearchExampleTests.cs new file mode 100644 index 000000000..feb6f9edf --- /dev/null +++ b/tests/BotSharp.Core.UnitTests/MCP/ParallelSearchExampleTests.cs @@ -0,0 +1,186 @@ +using System.Net; +using System.Text; +using System.Text.Json; +using BotSharp.Abstraction.Agents.Models; +using BotSharp.Abstraction.Agents.Settings; +using BotSharp.Abstraction.Conversations; +using BotSharp.Abstraction.Infrastructures; +using BotSharp.Abstraction.MCP.Services; +using BotSharp.Abstraction.MessageHub.Models; +using BotSharp.Abstraction.Conversations.Models; +using BotSharp.Core.Infrastructures; +using BotSharp.Core.MCP; +using BotSharp.Core.MCP.Hooks; +using BotSharp.Core.MCP.Managers; +using BotSharp.Core.MCP.Settings; +using BotSharp.Core.MessageHub; +using BotSharp.Core.Routing.Executor; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; +using Xunit.Abstractions; + +namespace BotSharp.Core.UnitTests.MCP; + +public class ParallelSearchExampleTests(ITestOutputHelper output) +{ + private const string ServerId = "parallel-search"; + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void Empty_or_disabled_configuration_does_not_register_a_client(bool enabled) + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["MCP:Enabled"] = enabled.ToString() + }).Build(); + var services = new ServiceCollection().AddLogging(); + services.AddBotSharpMCP(config); + using var provider = services.BuildServiceProvider(); + Assert.Null(provider.GetService()); + Assert.Empty(provider.GetRequiredService().McpServerConfigs); + } + + [Fact] + public async Task Configuration_loads_agent_tools_and_executes_search_and_fetch() + { + // Only this explicit switch sends requests to the public service. CI uses a local fixture. + var live = Environment.GetEnvironmentVariable("BOTSHARP_PARALLEL_LIVE") == "1"; + var config = new ConfigurationBuilder() + .AddJsonFile(Path.Combine(AppContext.BaseDirectory, "parallel-search.json")) + .Build(); + var services = new ServiceCollection(); + services.AddLogging(x => x.AddConsole()); + services.AddBotSharpMCP(config); + services.AddSingleton(); + var state = new Mock(); + var conversation = new Mock(); + conversation.Setup(x => x.IsConversationMode()).Returns(true); + conversation.SetupGet(x => x.States).Returns(state.Object); + services.AddSingleton(conversation.Object); + services.AddSingleton>>(); + services.AddSingleton(new AgentSettings()); + var requests = new List(); + services.AddHttpClient($"mcp:{ServerId}") + .AddHttpMessageHandler(() => new RecordingHandler(requests, live)); + await using var provider = services.BuildServiceProvider(); + using var scope = provider.CreateScope(); + var sp = scope.ServiceProvider; + var settings = sp.GetRequiredService(); + Assert.True(settings.Enabled); + var server = Assert.Single(settings.McpServerConfigs); + Assert.Equal("https://search.parallel.ai/mcp", server.HttpConfig!.EndPoint); + Assert.Null(server.HttpConfig.AdditionalHeaders!.GetValueOrDefault("Authorization")); + output.WriteLine($"Mode: {(live ? "live keyless HTTP" : "local HTTP fixture")}"); + + var options = await sp.GetRequiredService().GetServerConfigsAsync(); + Assert.Contains("web_search", Assert.Single(options).Tools); + Assert.Contains("web_fetch", Assert.Single(options).Tools); + var agent = new Agent + { + McpTools = [new McpTool("Parallel Search", ServerId, functions: + [new McpFunction("web_search"), new McpFunction("web_fetch")])] + }; + var hook = new McpToolAgentHook(sp, sp.GetRequiredService()); + await hook.OnAgentMcpToolLoaded(agent); + Assert.Contains(agent.SecondaryFunctions, x => x.Name == "web_search"); + Assert.Contains(agent.SecondaryFunctions, x => x.Name == "web_fetch"); + + var sessionId = Guid.NewGuid().ToString(); + var factory = new FunctionExecutorFactory(sp); + var search = new RoleDialogModel + { + FunctionName = "web_search", + FunctionArgs = JsonSerializer.Serialize(new + { + objective = "Find Microsoft's official documentation explaining what .NET is.", + search_queries = new[] { "site:learn.microsoft.com dotnet introduction" }, + session_id = sessionId + }) + }; + var searchExecutor = Assert.IsType(factory.Create(search.FunctionName, agent)); + Assert.True(await searchExecutor.ExecuteAsync(search), search.Content); + Assert.Contains("https://", search.Content); + Assert.Contains(".NET", search.Content, StringComparison.OrdinalIgnoreCase); + output.WriteLine("Search output: " + search.Content); + + // Fetch a specific page for exact documentation, rather than fetching every search result. + var fetch = new RoleDialogModel + { + FunctionName = "web_fetch", + FunctionArgs = JsonSerializer.Serialize(new + { + urls = new[] { "https://learn.microsoft.com/en-us/dotnet/core/introduction" }, + objective = "Explain what .NET is using the official introduction.", + session_id = sessionId + }) + }; + var fetchExecutor = Assert.IsType(factory.Create(fetch.FunctionName, agent)); + Assert.True(await fetchExecutor.ExecuteAsync(fetch), fetch.Content); + Assert.Contains(".NET", fetch.Content, StringComparison.OrdinalIgnoreCase); + Assert.Contains("https://learn.microsoft.com", fetch.Content); + output.WriteLine("Fetch output: " + fetch.Content); + Assert.Contains(requests, x => x == "tools/list"); + Assert.Contains(requests, x => x == "tools/call:web_search"); + Assert.Contains(requests, x => x == "tools/call:web_fetch"); + output.WriteLine("Observed requests with BotSharp User-Agent and no auth: " + string.Join(", ", requests)); + + agent.McpTools[0].Disabled = true; + agent.SecondaryFunctions.Clear(); + var count = requests.Count; + await hook.OnAgentMcpToolLoaded(agent); + Assert.Empty(agent.SecondaryFunctions); + Assert.Equal(count, requests.Count); + server.Enabled = false; + Assert.Null(await sp.GetRequiredService().GetMcpClientAsync(ServerId)); + Assert.Equal(count, requests.Count); + } + + private sealed class RecordingHandler(List requests, bool live) : DelegatingHandler + { + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Assert.Contains("BotSharp/5.2.0", request.Headers.UserAgent.ToString()); + Assert.False(request.Headers.Contains("Authorization")); + Assert.False(request.Headers.Contains("x-api-key")); + if (request.Method != HttpMethod.Post) + return live ? await base.SendAsync(request, cancellationToken) : new HttpResponseMessage(HttpStatusCode.MethodNotAllowed); + + using var json = JsonDocument.Parse(await request.Content!.ReadAsStringAsync(cancellationToken)); + var rpc = json.RootElement; + var method = rpc.GetProperty("method").GetString(); + var tool = method == "tools/call" ? rpc.GetProperty("params").GetProperty("name").GetString() : null; + if (tool != null) + { + var args = rpc.GetProperty("params").GetProperty("arguments"); + Assert.True(Guid.TryParse(args.GetProperty("session_id").GetString(), out _)); + Assert.False(string.IsNullOrWhiteSpace(args.GetProperty("objective").GetString())); + Assert.Equal(JsonValueKind.Array, args.GetProperty(tool == "web_search" ? "search_queries" : "urls").ValueKind); + } + requests.Add(tool == null ? method! : $"{method}:{tool}"); + if (live) return await base.SendAsync(request, cancellationToken); + if (!rpc.TryGetProperty("id", out var id)) return new HttpResponseMessage(HttpStatusCode.Accepted); + object result = method switch + { + "initialize" => new { protocolVersion = "2025-03-26", capabilities = new { tools = new { } }, serverInfo = new { name = "Local search fixture", version = "1.0.0" } }, + "tools/list" => new { tools = new[] { Tool("web_search"), Tool("web_fetch") } }, + "tools/call" => new { content = new[] { new { type = "text", text = ".NET is a free, cross-platform developer platform. Source: https://learn.microsoft.com/en-us/dotnet/core/introduction" } }, isError = false }, + _ => throw new InvalidOperationException("Unexpected RPC method: " + method) + }; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(new { jsonrpc = "2.0", id, result }), Encoding.UTF8, "application/json") + }; + } + + private static object Tool(string name) => new + { + name, + description = "Local fixture for " + name, + inputSchema = new { type = "object", properties = new { }, required = Array.Empty() } + }; + } +}