Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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::
Expand Down
24 changes: 24 additions & 0 deletions docs/mcp/parallel-search.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
]
}
}
57 changes: 57 additions & 0 deletions docs/mcp/parallel-search.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 4 additions & 0 deletions tests/BotSharp.Core.UnitTests/BotSharp.Core.UnitTests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<None Include="..\..\docs\mcp\parallel-search.json" Link="parallel-search.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\Infrastructure\BotSharp.Core\BotSharp.Core.csproj" />
<ProjectReference Include="..\..\src\Infrastructure\BotSharp.Core.Rules\BotSharp.Core.Rules.csproj" />
Expand Down
186 changes: 186 additions & 0 deletions tests/BotSharp.Core.UnitTests/MCP/ParallelSearchExampleTests.cs
Original file line number Diff line number Diff line change
@@ -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<string, string?>
{
["MCP:Enabled"] = enabled.ToString()
}).Build();
var services = new ServiceCollection().AddLogging();
services.AddBotSharpMCP(config);
using var provider = services.BuildServiceProvider();
Assert.Null(provider.GetService<McpClientManager>());
Assert.Empty(provider.GetRequiredService<McpSettings>().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<ICacheService, MemoryCacheService>();
var state = new Mock<IConversationStateService>();
var conversation = new Mock<IConversationService>();
conversation.Setup(x => x.IsConversationMode()).Returns(true);
conversation.SetupGet(x => x.States).Returns(state.Object);
services.AddSingleton(conversation.Object);
services.AddSingleton<MessageHub<HubObserveData<RoleDialogModel>>>();
services.AddSingleton(new AgentSettings());
var requests = new List<string>();
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<McpSettings>();
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<IMcpService>().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<AgentSettings>());
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<McpToolExecutor>(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<McpToolExecutor>(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<McpClientManager>().GetMcpClientAsync(ServerId));
Assert.Equal(count, requests.Count);
}

private sealed class RecordingHandler(List<string> requests, bool live) : DelegatingHandler
{
protected override async Task<HttpResponseMessage> 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<string>() }
};
}
}