diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..433a953 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,56 @@ +# Claude Code Project Memory + +## Git Conventions + +### Commit Authorship +- **Author Name**: Gunpal Jain +- **Author Email**: gunpal5@gmail.com +- **Do NOT use "Claude" or "noreply@anthropic.com"** - all commits should be authored by Gunpal Jain + +### Branch Naming +- Use descriptive branch names without "claude" prefix +- Preferred formats: + - `feature/description` - for new features + - `fix/description` - for bug fixes + - `refactor/description` - for refactoring + - `docs/description` - for documentation + - `mcp-integration` or similar descriptive names + +**Important**: Do NOT use "claude/" prefix in branch names unless technically required by the environment. + +### Commit Messages +- Use conventional commit format: `type: description` +- Types: `feat`, `fix`, `docs`, `refactor`, `test`, `chore` +- Keep messages clear and professional +- No need to mention "Claude" or AI assistance in commit messages + +## Repository Information + +### Project +Google_GenerativeAI - Unofficial C# .NET SDK for Google Generative AI (Gemini) and Vertex AI + +### Owner +- Name: Gunpal Jain +- GitHub: gunpal5 +- Email: gunpal5@gmail.com + +### Key Directories +- `/src/GenerativeAI/` - Core SDK +- `/src/GenerativeAI.Tools/` - Tool implementations +- `/src/GenerativeAI.Microsoft/` - Microsoft.Extensions.AI integration +- `/samples/` - Example projects +- `/tests/` - Test projects + +### Current Work +MCP (Model Context Protocol) integration with support for all transport protocols (stdio, HTTP/SSE). + +### Code Style +- C# with latest language features +- Nullable reference types enabled +- Comprehensive XML documentation +- Follow existing patterns in the codebase + +## Notes +- When creating PRs, ensure clear descriptions and link related issues +- Run tests before committing (when possible) +- Follow semantic versioning for releases diff --git a/README.md b/README.md index 3282111..66470dd 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,7 @@ - [Gemini Tools and Function Calling](#gemini-tools-and-function-calling) - [1. Inbuilt Tools (GoogleSearch, GoogleSearchRetrieval, and Code Execution)](#gemini-tools-and-function-calling) - [2. Function Calling](#gemini-tools-and-function-calling) + - [3. MCP Server Integration](#4-mcp-model-context-protocol-server-integration) - [Image Generation and Captioning](#image-generation-and-captioning) - [Multimodal Live API](#multimodal-live-api) - [Retrieval-Augmented Generation](#retrieval-augmented-generation) @@ -504,6 +505,68 @@ model.AddFunctionTool(service.AsGoogleFunctionTool()); --- **For more details and options, see the [wiki](https://github.com/gunpal5/Google_GenerativeAI/wiki/Function-Calling).** + +--- + + - ### 4. MCP (Model Context Protocol) Server Integration + +Integrate MCP servers to expose tools from any MCP-compatible server to Gemini. Supports **all transport protocols**: stdio, HTTP/SSE, and custom transports. + +**Stdio Transport** (Launch MCP server as subprocess): +```csharp +// Create stdio transport +var transport = McpTransportFactory.CreateStdioTransport( + "my-server", + "npx", + new[] { "-y", "@modelcontextprotocol/server-everything" } +); + +using var mcpTool = await McpTool.CreateAsync(transport); + +var model = new GenerativeModel("YOUR_API_KEY", GoogleAIModels.Gemini2Flash); +model.AddFunctionTool(mcpTool); +model.FunctionCallingBehaviour.AutoCallFunction = true; +``` + +**HTTP/SSE Transport** (Connect to remote MCP server): +```csharp +// Create HTTP transport +var transport = McpTransportFactory.CreateHttpTransport("http://localhost:8080"); + +// Or with authentication +var authTransport = McpTransportFactory.CreateHttpTransportWithAuth( + "https://api.example.com", + "your-auth-token" +); + +using var mcpTool = await McpTool.CreateAsync(transport); +model.AddFunctionTool(mcpTool); +``` + +**Multiple MCP Servers**: +```csharp +var transports = new List +{ + McpTransportFactory.CreateStdioTransport("server1", "npx", new[] { "..." }), + McpTransportFactory.CreateHttpTransport("http://localhost:8080") +}; + +var mcpTools = await McpTool.CreateMultipleAsync(transports); +foreach (var tool in mcpTools) +{ + model.AddFunctionTool(tool); +} +``` + +**Key Features**: +- Supports stdio, HTTP/SSE, and custom transports +- Auto-discovery of tools from MCP servers +- Multiple concurrent servers +- Auto-reconnection support +- Works with any MCP-compatible server (Node.js, Python, C#, etc.) + +**See [samples/McpIntegrationDemo](samples/McpIntegrationDemo) for complete examples.** + --- ## Image Generation and Captioning diff --git a/samples/McpIntegrationDemo/McpIntegrationDemo.csproj b/samples/McpIntegrationDemo/McpIntegrationDemo.csproj new file mode 100644 index 0000000..1350c0f --- /dev/null +++ b/samples/McpIntegrationDemo/McpIntegrationDemo.csproj @@ -0,0 +1,16 @@ + + + + Exe + net8.0 + enable + enable + latest + + + + + + + + diff --git a/samples/McpIntegrationDemo/Program.cs b/samples/McpIntegrationDemo/Program.cs new file mode 100644 index 0000000..b39783f --- /dev/null +++ b/samples/McpIntegrationDemo/Program.cs @@ -0,0 +1,309 @@ +using GenerativeAI; +using GenerativeAI.Tools.Mcp; +using ModelContextProtocol.Transports; + +namespace McpIntegrationDemo; + +/// +/// Demonstrates how to integrate MCP (Model Context Protocol) servers with Google Generative AI. +/// Shows examples for all transport types: stdio, HTTP/SSE. +/// +class Program +{ + static async Task Main(string[] args) + { + Console.WriteLine("=== MCP Integration Demo for Google Generative AI ===\n"); + Console.WriteLine("Supports all MCP transport protocols: stdio, HTTP/SSE\n"); + + // Get API key from environment variable + var apiKey = Environment.GetEnvironmentVariable("GEMINI_API_KEY"); + if (string.IsNullOrEmpty(apiKey)) + { + Console.WriteLine("Error: GEMINI_API_KEY environment variable not set."); + Console.WriteLine("Please set it with: export GEMINI_API_KEY=your-api-key"); + return; + } + + try + { + // Example 1: Stdio Transport + await StdioTransportExample(apiKey); + + Console.WriteLine("\n" + new string('=', 60) + "\n"); + + // Example 2: HTTP/SSE Transport + await HttpTransportExample(apiKey); + + Console.WriteLine("\n" + new string('=', 60) + "\n"); + + // Example 3: Multiple MCP Servers with Different Transports + await MultipleMcpServersExample(apiKey); + + Console.WriteLine("\n" + new string('=', 60) + "\n"); + + // Example 4: Custom Transport Configuration + await CustomConfigurationExample(apiKey); + + Console.WriteLine("\n" + new string('=', 60) + "\n"); + + // Example 5: Auto-Reconnection with Transport Factory + await AutoReconnectionExample(apiKey); + } + catch (Exception ex) + { + Console.WriteLine($"\nError: {ex.Message}"); + Console.WriteLine($"Stack trace: {ex.StackTrace}"); + } + } + + /// + /// Example 1: Using stdio transport to launch an MCP server as a subprocess + /// + static async Task StdioTransportExample(string apiKey) + { + Console.WriteLine("Example 1: Stdio Transport (Launch MCP Server as Subprocess)\n"); + + // Create stdio transport using the factory + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + Console.WriteLine("Connecting to MCP server via stdio..."); + + // Create and connect to the MCP server + using var mcpTool = await McpTool.CreateAsync(transport); + + Console.WriteLine($"Connected! Available functions: {string.Join(", ", mcpTool.GetAvailableFunctions())}"); + Console.WriteLine(); + + // Create a Gemini model and add the MCP tool + var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); + model.AddFunctionTool(mcpTool); + + // Enable automatic function calling + model.FunctionCallingBehaviour.FunctionEnabled = true; + model.FunctionCallingBehaviour.AutoCallFunction = true; + model.FunctionCallingBehaviour.AutoReplyFunction = true; + + // Ask the model something that requires using the MCP tools + Console.WriteLine("Asking model: 'Use the echo tool to say Hello from stdio!'\n"); + + var result = await model.GenerateContentAsync("Use the echo tool to say 'Hello from stdio!'"); + Console.WriteLine($"Model response: {result.Text}"); + } + + /// + /// Example 2: Using HTTP/SSE transport to connect to a remote MCP server + /// + static async Task HttpTransportExample(string apiKey) + { + Console.WriteLine("Example 2: HTTP/SSE Transport (Connect to Remote MCP Server)\n"); + + // NOTE: This example requires a running MCP server on HTTP + // You can skip this if you don't have an HTTP MCP server running + Console.WriteLine("Checking if HTTP MCP server is available at http://localhost:8080..."); + + try + { + // Create HTTP transport using the factory + var transport = McpTransportFactory.CreateHttpTransport("http://localhost:8080"); + + Console.WriteLine("Connecting to MCP server via HTTP/SSE..."); + + // Create tool with a shorter timeout for this example + var options = new McpToolOptions + { + ConnectionTimeoutMs = 5000 // 5 seconds + }; + + using var mcpTool = await McpTool.CreateAsync(transport, options); + + Console.WriteLine($"Connected! Available functions: {string.Join(", ", mcpTool.GetAvailableFunctions())}"); + Console.WriteLine(); + + // Use with Gemini model + var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); + model.AddFunctionTool(mcpTool); + model.FunctionCallingBehaviour.AutoCallFunction = true; + + var result = await model.GenerateContentAsync("List the available tools"); + Console.WriteLine($"Model response: {result.Text}"); + } + catch (Exception ex) + { + Console.WriteLine($"Skipping HTTP example: {ex.Message}"); + Console.WriteLine("To run this example, start an MCP server with HTTP transport on port 8080"); + } + } + + /// + /// Example 3: Connecting to multiple MCP servers with different transports + /// + static async Task MultipleMcpServersExample(string apiKey) + { + Console.WriteLine("Example 3: Multiple MCP Servers with Different Transports\n"); + + // Create transports for multiple servers + var transports = new List + { + McpTransportFactory.CreateStdioTransport( + "server-1", + "npx", + new[] { "-y", "@modelcontextprotocol/server-everything" } + ), + // You can mix different transport types + // McpTransportFactory.CreateHttpTransport("http://localhost:8080"), + // McpTransportFactory.CreateStdioTransport( + // "python-server", + // "python", + // new[] { "weather_mcp_server.py" } + // ) + }; + + Console.WriteLine("Connecting to multiple MCP servers..."); + + // Create all MCP tools + var mcpTools = await McpTool.CreateMultipleAsync(transports); + + foreach (var tool in mcpTools) + { + Console.WriteLine($" - {tool.GetAvailableFunctions().Count} functions from MCP server"); + } + Console.WriteLine(); + + // Create a model and add all MCP tools + var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); + foreach (var tool in mcpTools) + { + model.AddFunctionTool(tool); + } + + model.FunctionCallingBehaviour.FunctionEnabled = true; + model.FunctionCallingBehaviour.AutoCallFunction = true; + model.FunctionCallingBehaviour.AutoReplyFunction = true; + + Console.WriteLine("Asking model to use available tools...\n"); + + var result = await model.GenerateContentAsync( + "List all the tools you have access to and give me a brief description of what they can do."); + Console.WriteLine($"Model response:\n{result.Text}"); + + // Cleanup + foreach (var tool in mcpTools) + { + await tool.DisposeAsync(); + } + } + + /// + /// Example 4: Custom transport configuration with environment variables and headers + /// + static async Task CustomConfigurationExample(string apiKey) + { + Console.WriteLine("Example 4: Custom Transport Configuration\n"); + + // Example 1: Stdio with environment variables + Console.WriteLine("Creating stdio transport with custom environment variables..."); + + var stdioTransport = McpTransportFactory.CreateStdioTransport( + name: "custom-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" }, + environmentVariables: new Dictionary + { + { "NODE_ENV", "production" }, + { "LOG_LEVEL", "debug" } + }, + workingDirectory: null + ); + + // Example 2: HTTP with authentication + Console.WriteLine("Example HTTP transport with authentication (not connecting):"); + Console.WriteLine(" var httpTransport = McpTransportFactory.CreateHttpTransportWithAuth("); + Console.WriteLine(" \"http://api.example.com\","); + Console.WriteLine(" \"your-auth-token\""); + Console.WriteLine(" );"); + Console.WriteLine(); + + // Custom tool options + var toolOptions = new McpToolOptions + { + ConnectionTimeoutMs = 60000, // 60 seconds + AutoReconnect = true, + MaxReconnectAttempts = 5, + ThrowOnToolCallFailure = false, + IncludeDetailedErrors = true + }; + + Console.WriteLine("Connecting with custom options..."); + + using var mcpTool = await McpTool.CreateAsync(stdioTransport, toolOptions); + + Console.WriteLine("Connected!"); + Console.WriteLine($"Is Connected: {mcpTool.IsConnected}"); + Console.WriteLine($"Available Functions: {mcpTool.GetAvailableFunctions().Count}"); + Console.WriteLine(); + + // Display detailed information about each function + Console.WriteLine("Function Details:"); + foreach (var functionName in mcpTool.GetAvailableFunctions()) + { + var info = mcpTool.GetFunctionInfo(functionName); + if (info != null) + { + Console.WriteLine($" - {info.Name}"); + Console.WriteLine($" Description: {info.Description ?? "N/A"}"); + } + } + Console.WriteLine(); + + // Use the tool with a model + var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); + model.AddFunctionTool(mcpTool); + model.FunctionCallingBehaviour.AutoCallFunction = true; + + // You can also manually refresh tools if the MCP server updates + Console.WriteLine("Refreshing tools from MCP server..."); + await mcpTool.RefreshToolsAsync(); + Console.WriteLine($"Tools refreshed. Count: {mcpTool.GetAvailableFunctions().Count}"); + } + + /// + /// Example 5: Auto-reconnection using transport factory + /// + static async Task AutoReconnectionExample(string apiKey) + { + Console.WriteLine("Example 5: Auto-Reconnection with Transport Factory\n"); + + // Use a factory function for auto-reconnection support + // The factory will be called to create a new transport if reconnection is needed + using var mcpTool = await McpTool.CreateAsync( + transportFactory: () => McpTransportFactory.CreateStdioTransport( + "reconnectable-server", + "npx", + new[] { "-y", "@modelcontextprotocol/server-everything" } + ), + options: new McpToolOptions + { + AutoReconnect = true, + MaxReconnectAttempts = 3 + } + ); + + Console.WriteLine("Connected with auto-reconnection support!"); + Console.WriteLine($"Available functions: {mcpTool.GetAvailableFunctions().Count}"); + Console.WriteLine(); + Console.WriteLine("If the connection is lost, McpTool will automatically attempt to reconnect"); + Console.WriteLine("up to 3 times using the transport factory.\n"); + + // Use with Gemini + var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); + model.AddFunctionTool(mcpTool); + model.FunctionCallingBehaviour.AutoCallFunction = true; + + var result = await model.GenerateContentAsync("Use echo to say 'Auto-reconnection enabled!'"); + Console.WriteLine($"Model response: {result.Text}"); + } +} diff --git a/src/GenerativeAI.Tools/GenerativeAI.Tools.csproj b/src/GenerativeAI.Tools/GenerativeAI.Tools.csproj index c283a72..8edd788 100644 --- a/src/GenerativeAI.Tools/GenerativeAI.Tools.csproj +++ b/src/GenerativeAI.Tools/GenerativeAI.Tools.csproj @@ -33,10 +33,12 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive + diff --git a/src/GenerativeAI.Tools/Mcp/McpServerConfig.cs b/src/GenerativeAI.Tools/Mcp/McpServerConfig.cs new file mode 100644 index 0000000..adb6c84 --- /dev/null +++ b/src/GenerativeAI.Tools/Mcp/McpServerConfig.cs @@ -0,0 +1,140 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using ModelContextProtocol.Client; + +namespace GenerativeAI.Tools.Mcp; + +/// +/// Configuration options for the McpTool. +/// +public class McpToolOptions +{ + /// + /// Gets or sets the timeout for connecting to the MCP server (in milliseconds). + /// Default is 30000ms (30 seconds). + /// + public int ConnectionTimeoutMs { get; set; } = 30000; + + /// + /// Gets or sets whether to automatically reconnect to the MCP server if the connection is lost. + /// Default is true. + /// + public bool AutoReconnect { get; set; } = true; + + /// + /// Gets or sets the maximum number of reconnection attempts. + /// Default is 3. + /// + public int MaxReconnectAttempts { get; set; } = 3; + + /// + /// Gets or sets whether to throw exceptions on tool call failures. + /// If false, failures will be returned as error responses. + /// Default is false. + /// + public bool ThrowOnToolCallFailure { get; set; } = false; + + /// + /// Gets or sets whether to include detailed error information in function responses. + /// Default is true. + /// + public bool IncludeDetailedErrors { get; set; } = true; +} + +/// +/// Factory class for creating MCP client transports with common configurations. +/// Supports stdio, HTTP/SSE, and other transport protocols. +/// +public static class McpTransportFactory +{ + /// + /// Creates a stdio transport for launching an MCP server as a subprocess. + /// + /// The name of the MCP server. + /// The command to execute (e.g., "npx", "python", "node"). + /// Command-line arguments. + /// Optional environment variables. + /// Optional working directory. + /// A configured StdioClientTransport. + public static IClientTransport CreateStdioTransport( + string name, + string command, + IEnumerable arguments, + IDictionary? environmentVariables = null, + string? workingDirectory = null) + { + var options = new StdioClientTransportOptions + { + Name = name, + Command = command, + Arguments = new List(arguments), + EnvironmentVariables = environmentVariables != null + ? new Dictionary(environmentVariables) + : null, + WorkingDirectory = workingDirectory + }; + + return new StdioClientTransport(options); + } + + /// + /// Creates an HTTP/SSE transport for connecting to a remote MCP server via HTTP. + /// + /// The base URL of the MCP server (e.g., "http://localhost:8080"). + /// Optional custom HttpClient instance. + /// Optional additional HTTP headers. + /// A configured HttpClientTransport. + public static IClientTransport CreateHttpTransport( + string baseUrl, + HttpClient? httpClient = null, + IDictionary? additionalHeaders = null) + { + var options = new HttpClientTransportOptions + { + Endpoint = new Uri(baseUrl), + AdditionalHeaders = additionalHeaders != null + ? new Dictionary(additionalHeaders) + : null + }; + + return httpClient != null + ? new HttpClientTransport(options, httpClient) + : new HttpClientTransport(options); + } + + /// + /// Creates an HTTP/SSE transport with authentication headers. + /// + /// The base URL of the MCP server. + /// Authentication token (will be sent as "Authorization: Bearer {token}"). + /// Optional custom HttpClient instance. + /// A configured HttpClientTransport with authentication. + public static IClientTransport CreateHttpTransportWithAuth( + string baseUrl, + string authToken, + HttpClient? httpClient = null) + { + var headers = new Dictionary + { + { "Authorization", $"Bearer {authToken}" } + }; + + return CreateHttpTransport(baseUrl, httpClient, headers); + } + + /// + /// Creates an HTTP/SSE transport with custom headers. + /// + /// The base URL of the MCP server. + /// Custom HTTP headers. + /// Optional custom HttpClient instance. + /// A configured HttpClientTransport with custom headers. + public static IClientTransport CreateHttpTransportWithHeaders( + string baseUrl, + IDictionary headers, + HttpClient? httpClient = null) + { + return CreateHttpTransport(baseUrl, httpClient, headers); + } +} diff --git a/src/GenerativeAI.Tools/Mcp/McpTool.cs b/src/GenerativeAI.Tools/Mcp/McpTool.cs new file mode 100644 index 0000000..2eae1d0 --- /dev/null +++ b/src/GenerativeAI.Tools/Mcp/McpTool.cs @@ -0,0 +1,487 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using System.Threading.Tasks; +using GenerativeAI.Core; +using GenerativeAI.Types; +using ModelContextProtocol; +using ModelContextProtocol.Client; + +namespace GenerativeAI.Tools.Mcp; + +/// +/// A tool implementation that integrates MCP (Model Context Protocol) servers with Google Generative AI. +/// This allows you to expose tools from any MCP server to Gemini models for function calling. +/// Supports all MCP transport protocols: stdio, HTTP/SSE, and custom transports. +/// +/// +/// +/// McpTool connects to an MCP server via the official C# SDK and automatically discovers +/// and exposes all available tools from that server. When the model requests to call a function, +/// McpTool forwards the request to the MCP server and returns the response. +/// +/// +/// Example usage with stdio transport: +/// +/// var transport = McpTransportFactory.CreateStdioTransport( +/// "my-server", +/// "npx", +/// new[] { "-y", "@modelcontextprotocol/server-everything" } +/// ); +/// +/// using var mcpTool = await McpTool.CreateAsync(transport); +/// +/// var model = new GenerativeModel(apiKey, "gemini-2.0-flash-exp"); +/// model.AddFunctionTool(mcpTool); +/// +/// var result = await model.GenerateContentAsync("Your query here"); +/// +/// +/// +/// Example usage with HTTP transport: +/// +/// var transport = McpTransportFactory.CreateHttpTransport("http://localhost:8080"); +/// +/// using var mcpTool = await McpTool.CreateAsync(transport); +/// // Use as above +/// +/// +/// +public class McpTool : GoogleFunctionTool, IDisposable, IAsyncDisposable +{ + private readonly Func? _transportFactory; + private readonly McpToolOptions _options; + private McpClient? _client; + private IClientTransport? _transport; + private List _functionDeclarations; + private Dictionary _mcpTools; + private bool _disposed; + private int _reconnectAttempts; + private readonly SemaphoreSlim _connectionLock = new SemaphoreSlim(1, 1); + + /// + /// Gets the MCP client instance. May be null if not connected. + /// + public McpClient? Client => _client; + + /// + /// Gets whether the tool is currently connected to the MCP server. + /// + public bool IsConnected => _client != null && _transport != null; + + private McpTool(IClientTransport transport, McpToolOptions? options = null) + { + _transport = transport ?? throw new ArgumentNullException(nameof(transport)); + _options = options ?? new McpToolOptions(); + _functionDeclarations = new List(); + _mcpTools = new Dictionary(); + _transportFactory = null; + } + + private McpTool(Func transportFactory, McpToolOptions? options = null) + { + _transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory)); + _options = options ?? new McpToolOptions(); + _functionDeclarations = new List(); + _mcpTools = new Dictionary(); + } + + /// + /// Creates a new McpTool instance and connects using the provided transport. + /// Supports all MCP transport types: stdio, HTTP/SSE, etc. + /// + /// The MCP client transport (stdio, HTTP, etc.). + /// Optional configuration options for the tool behavior. + /// Token to monitor for cancellation requests. + /// A connected McpTool instance ready to use. + /// Thrown when transport is null. + /// Thrown when unable to connect to the MCP server. + public static async Task CreateAsync( + IClientTransport transport, + McpToolOptions? options = null, + CancellationToken cancellationToken = default) + { + var tool = new McpTool(transport, options); + await tool.ConnectAsync(cancellationToken).ConfigureAwait(false); + return tool; + } + + /// + /// Creates a new McpTool instance with a transport factory for auto-reconnection support. + /// The factory will be called to create a new transport when reconnection is needed. + /// + /// Factory function that creates a new transport instance. + /// Optional configuration options for the tool behavior. + /// Token to monitor for cancellation requests. + /// A connected McpTool instance ready to use. + public static async Task CreateAsync( + Func transportFactory, + McpToolOptions? options = null, + CancellationToken cancellationToken = default) + { + var tool = new McpTool(transportFactory, options); + tool._transport = transportFactory(); + await tool.ConnectAsync(cancellationToken).ConfigureAwait(false); + return tool; + } + + /// + /// Creates multiple McpTool instances from a list of transports. + /// + /// List of MCP client transports. + /// Optional configuration options for the tool behavior. + /// Token to monitor for cancellation requests. + /// A list of connected McpTool instances. + public static async Task> CreateMultipleAsync( + IEnumerable transports, + McpToolOptions? options = null, + CancellationToken cancellationToken = default) + { + var tasks = transports.Select(transport => CreateAsync(transport, options, cancellationToken)); + var tools = await Task.WhenAll(tasks).ConfigureAwait(false); + return tools.ToList(); + } + + private async Task ConnectAsync(CancellationToken cancellationToken) + { + await _connectionLock.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + if (_transport == null) + throw new InvalidOperationException("Transport is not initialized"); + + // Create MCP client + using var timeoutCts = new CancellationTokenSource(_options.ConnectionTimeoutMs); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token); + + _client = await McpClient.CreateAsync(_transport, cancellationToken:linkedCts.Token).ConfigureAwait(false); + + // Discover available tools + await RefreshToolsAsync(cancellationToken).ConfigureAwait(false); + + _reconnectAttempts = 0; + } + finally + { + _connectionLock.Release(); + } + } + + /// + /// Refreshes the list of available tools from the MCP server. + /// + /// Token to monitor for cancellation requests. + public async Task RefreshToolsAsync(CancellationToken cancellationToken = default) + { + if (_client == null) + throw new InvalidOperationException("MCP client is not connected. Call ConnectAsync first."); + + var tools = await _client.ListToolsAsync(cancellationToken:cancellationToken).ConfigureAwait(false); + + _mcpTools.Clear(); + _functionDeclarations.Clear(); + + foreach (var tool in tools) + { + _mcpTools[tool.Name] = tool; + _functionDeclarations.Add(ConvertMcpToolToFunctionDeclaration(tool)); + } + } + + private FunctionDeclaration ConvertMcpToolToFunctionDeclaration(McpClientTool mcpTool) + { + var declaration = new FunctionDeclaration + { + Name = mcpTool.Name, + Description = mcpTool.Description ?? string.Empty + }; + + // McpClientTool.JsonSchema contains the JSON Schema for the tool's parameters + if (mcpTool.JsonSchema.ValueKind != JsonValueKind.Undefined && mcpTool.JsonSchema.ValueKind != JsonValueKind.Null) + { + declaration.ParametersJsonSchema = JsonNode.Parse(mcpTool.JsonSchema.GetRawText()); + } + + return declaration; + } + + /// + public override Tool AsTool() + { + return new Tool + { + FunctionDeclarations = _functionDeclarations + }; + } + + /// + public override async Task CallAsync( + FunctionCall functionCall, + CancellationToken cancellationToken = default) + { + if (functionCall == null) + throw new ArgumentNullException(nameof(functionCall)); + + if (!IsContainFunction(functionCall.Name)) + { + throw new ArgumentException($"Function '{functionCall.Name}' is not available in this MCP server."); + } + + try + { + // Ensure we're connected + if (!IsConnected && _options.AutoReconnect) + { + await TryReconnectAsync(cancellationToken).ConfigureAwait(false); + } + + if (_client == null) + { + throw new InvalidOperationException("MCP client is not connected."); + } + + // Convert arguments to dictionary + var arguments = new Dictionary(); + if (functionCall.Args != null) + { + var jsonElement = JsonSerializer.Deserialize(functionCall.Args.ToJsonString()); + foreach (var property in jsonElement.EnumerateObject()) + { + arguments[property.Name] = JsonSerializer.Deserialize(property.Value.GetRawText()); + } + } + + // Call the MCP tool + var result = await _client.CallToolAsync( + functionCall.Name, + arguments, + cancellationToken:cancellationToken + ).ConfigureAwait(false); + + // Convert MCP response to FunctionResponse + var responseNode = new JsonObject + { + ["name"] = functionCall.Name + }; + + // MCP returns content as a list of content items + if (result.Content != null && result.Content.Any()) + { + var contentArray = new JsonArray(); + foreach (var content in result.Content) + { + // Serialize the entire ContentBlock as-is + // The MCP SDK handles the polymorphic serialization correctly + var contentJson = JsonSerializer.Serialize(content); + var contentNode = JsonNode.Parse(contentJson); + contentArray.Add(contentNode); + } + + responseNode["content"] = contentArray; + } + else + { + responseNode["content"] = string.Empty; + } + + // Include error information if present + if (result.IsError == true) + { + responseNode["isError"] = true; + if (_options.IncludeDetailedErrors) + { + responseNode["error"] = "Tool execution resulted in an error"; + } + } + + return new FunctionResponse + { + Id = functionCall.Id, + Name = functionCall.Name, + Response = responseNode + }; + } + catch (Exception ex) + { + if (_options.ThrowOnToolCallFailure) + { + throw; + } + + // Return error as function response + var errorResponse = new JsonObject + { + ["name"] = functionCall.Name, + ["error"] = _options.IncludeDetailedErrors + ? $"{ex.GetType().Name}: {ex.Message}" + : "Tool execution failed" + }; + + return new FunctionResponse + { + Id = functionCall.Id, + Name = functionCall.Name, + Response = errorResponse + }; + } + } + + /// + public override bool IsContainFunction(string name) + { + return _mcpTools.ContainsKey(name); + } + + /// + /// Gets a list of all available function names from the MCP server. + /// + /// List of function names. + public IReadOnlyList GetAvailableFunctions() + { + return _mcpTools.Keys.ToList(); + } + + /// + /// Gets detailed information about a specific function. + /// + /// The name of the function. + /// The FunctionDeclaration for the specified function, or null if not found. + public FunctionDeclaration? GetFunctionInfo(string functionName) + { + return _functionDeclarations.FirstOrDefault(f => f.Name == functionName); + } + + private async Task TryReconnectAsync(CancellationToken cancellationToken) + { + if (_reconnectAttempts >= _options.MaxReconnectAttempts) + { + throw new InvalidOperationException( + $"Failed to reconnect to MCP server after {_options.MaxReconnectAttempts} attempts."); + } + + _reconnectAttempts++; + + // Dispose old connection + if (_client != null) + { + await _client.DisposeAsync().ConfigureAwait(false); + _client = null; + } + + if (_transport != null) + { + if (_transport is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (_transport is IDisposable disposable) + { + disposable.Dispose(); + } + _transport = null; + } + + // Create new transport if factory is available + if (_transportFactory != null) + { + _transport = _transportFactory(); + } + else + { + throw new InvalidOperationException( + "Cannot reconnect without a transport factory. Use CreateAsync with a factory function for auto-reconnection support."); + } + + // Reconnect + await ConnectAsync(cancellationToken).ConfigureAwait(false); + } + + /// + /// Disposes the McpTool and closes the connection to the MCP server. + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Asynchronously disposes the McpTool and closes the connection to the MCP server. + /// + public async ValueTask DisposeAsync() + { + await DisposeAsyncCore().ConfigureAwait(false); + Dispose(false); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_disposed) + return; + + if (disposing) + { + _connectionLock.Dispose(); + + if (_client != null) + { + // For sync dispose, we can't await, so just dispose synchronously + try + { + _client.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + catch + { + // Best effort cleanup + } + } + + if (_transport != null) + { + try + { + if (_transport is IAsyncDisposable asyncDisposable) + { + asyncDisposable.DisposeAsync().AsTask().GetAwaiter().GetResult(); + } + else if (_transport is IDisposable disposable) + { + disposable.Dispose(); + } + } + catch + { + // Best effort cleanup + } + } + } + + _disposed = true; + } + + protected virtual async ValueTask DisposeAsyncCore() + { + if (_client != null) + { + await _client.DisposeAsync().ConfigureAwait(false); + } + + if (_transport != null) + { + if (_transport is IAsyncDisposable asyncDisposable) + { + await asyncDisposable.DisposeAsync().ConfigureAwait(false); + } + else if (_transport is IDisposable disposable) + { + disposable.Dispose(); + } + } + + _connectionLock.Dispose(); + } +} \ No newline at end of file diff --git a/tests/GenerativeAI.IntegrationTests/McpTool_RealCalls_Tests.cs b/tests/GenerativeAI.IntegrationTests/McpTool_RealCalls_Tests.cs new file mode 100644 index 0000000..229cf46 --- /dev/null +++ b/tests/GenerativeAI.IntegrationTests/McpTool_RealCalls_Tests.cs @@ -0,0 +1,373 @@ +using System; +using System.Linq; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using GenerativeAI.Tests; +using GenerativeAI.Tools.Mcp; +using GenerativeAI.Types; +using Shouldly; +using Xunit; + +namespace GenerativeAI.IntegrationTests; + +/// +/// Integration tests that make actual calls to MCP tools with real parameters. +/// These tests verify end-to-end functionality with live MCP server interaction. +/// +public class McpTool_RealCalls_Tests : TestBase +{ + public McpTool_RealCalls_Tests(ITestOutputHelper helper) : base(helper) + { + } + + [Fact] + public async Task ShouldInspectAvailableMcpTools() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + // Act + var availableFunctions = mcpTool.GetAvailableFunctions(); + var tool = mcpTool.AsTool(); + + // Assert + availableFunctions.ShouldNotBeEmpty(); + + Console.WriteLine("=== Available MCP Tools ==="); + Console.WriteLine($"Total tools: {availableFunctions.Count}"); + Console.WriteLine(""); + + foreach (var functionName in availableFunctions) + { + var functionInfo = mcpTool.GetFunctionInfo(functionName); + Console.WriteLine($"Tool: {functionName}"); + Console.WriteLine($" Description: {functionInfo?.Description}"); + + if (functionInfo?.ParametersJsonSchema != null) + { + Console.WriteLine($" Parameters: {functionInfo.ParametersJsonSchema.ToJsonString()}"); + } + Console.WriteLine(""); + } + + // Verify we have tools + availableFunctions.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task ShouldCallEchoTool() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var availableFunctions = mcpTool.GetAvailableFunctions(); + + Console.WriteLine($"Available functions: {string.Join(", ", availableFunctions)}"); + + // Look for an echo or similar simple tool + var echoTool = availableFunctions.FirstOrDefault(f => + f.Contains("echo", StringComparison.OrdinalIgnoreCase) || + f.Contains("add", StringComparison.OrdinalIgnoreCase) || + f.Contains("get", StringComparison.OrdinalIgnoreCase)); + + if (echoTool != null) + { + Console.WriteLine($"Testing tool: {echoTool}"); + + var functionInfo = mcpTool.GetFunctionInfo(echoTool); + Console.WriteLine($"Function info: {functionInfo?.ParametersJsonSchema?.ToJsonString()}"); + + // Create appropriate arguments based on the tool + var args = new JsonObject(); + + // Try to determine what parameters are needed + if (functionInfo?.ParametersJsonSchema != null) + { + var schema = functionInfo.ParametersJsonSchema.AsObject(); + if (schema.TryGetPropertyValue("properties", out var properties)) + { + var propsObj = properties?.AsObject(); + if (propsObj != null) + { + foreach (var prop in propsObj) + { + // Add sample values based on property name and type + if (prop.Key.Contains("message", StringComparison.OrdinalIgnoreCase) || + prop.Key.Contains("text", StringComparison.OrdinalIgnoreCase)) + { + args[prop.Key] = "Hello from MCP integration test!"; + } + else if (prop.Key.Contains("number", StringComparison.OrdinalIgnoreCase) || + prop.Key.Contains("count", StringComparison.OrdinalIgnoreCase)) + { + args[prop.Key] = 42; + } + else + { + args[prop.Key] = "test-value"; + } + } + } + } + } + + var functionCall = new FunctionCall + { + Name = echoTool, + Args = args + }; + + // Act + var response = await mcpTool.CallAsync(functionCall, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + response.Name.ShouldBe(echoTool); + response.Response.ShouldNotBeNull(); + + Console.WriteLine($"Response: {response.Response.ToJsonString()}"); + } + else + { + Console.WriteLine("No suitable test tool found. Available tools:"); + foreach (var tool in availableFunctions) + { + Console.WriteLine($" - {tool}"); + } + } + } + + [Fact] + public async Task ShouldCallMultipleMcpTools() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var availableFunctions = mcpTool.GetAvailableFunctions(); + + Console.WriteLine($"Testing with {availableFunctions.Count} available tools"); + + // Try calling the first few tools with empty or minimal arguments + var successfulCalls = 0; + var maxToTest = Math.Min(3, availableFunctions.Count); + + for (int i = 0; i < maxToTest; i++) + { + var functionName = availableFunctions[i]; + var functionInfo = mcpTool.GetFunctionInfo(functionName); + + Console.WriteLine($"\n=== Testing {functionName} ==="); + Console.WriteLine($"Description: {functionInfo?.Description}"); + + try + { + var functionCall = new FunctionCall + { + Name = functionName, + Args = new JsonObject() // Try with empty args first + }; + + var response = await mcpTool.CallAsync(functionCall, cancellationToken: TestContext.Current.CancellationToken); + + response.ShouldNotBeNull(); + Console.WriteLine($"Success! Response: {response.Response.ToJsonString()}"); + successfulCalls++; + } + catch (Exception ex) + { + Console.WriteLine($"Failed (expected if required args missing): {ex.Message}"); + } + } + + Console.WriteLine($"\n=== Summary: {successfulCalls}/{maxToTest} calls successful ==="); + } + + [Fact] + public async Task ShouldUseGeminiWithMcpTools() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + var availableFunctions = mcpTool.GetAvailableFunctions(); + Console.WriteLine($"MCP Server has {availableFunctions.Count} tools available"); + + var model = new GenerativeModel(GetTestGooglePlatform(), GoogleAIModels.DefaultGeminiModel); + model.AddFunctionTool(mcpTool); + + // Act - Ask the model what tools it has + var result = await model.GenerateContentAsync( + "List all the tools you have access to and briefly describe what each one does.", + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + result.ShouldNotBeNull(); + var responseText = result.Text(); + responseText.ShouldNotBeNullOrEmpty(); + + Console.WriteLine("=== Gemini's Response ==="); + Console.WriteLine(responseText); + + // The response should mention some of the tools + responseText.Length.ShouldBeGreaterThan(50); + } + + [Fact] + public async Task ShouldMakeActualToolCallThroughGemini() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + var availableFunctions = mcpTool.GetAvailableFunctions(); + Console.WriteLine($"Available MCP tools: {string.Join(", ", availableFunctions)}"); + + // Find a suitable tool to call + var testTool = availableFunctions.FirstOrDefault(f => + f.Contains("echo", StringComparison.OrdinalIgnoreCase) || + f.Contains("add", StringComparison.OrdinalIgnoreCase) || + f.Contains("time", StringComparison.OrdinalIgnoreCase) || + f.Contains("get", StringComparison.OrdinalIgnoreCase)); + + if (testTool != null) + { + Console.WriteLine($"Will ask Gemini to call: {testTool}"); + + var model = new GenerativeModel(GetTestGooglePlatform(), GoogleAIModels.DefaultGeminiModel); + model.AddFunctionTool(mcpTool); + + // Act - Ask Gemini to use the tool + var prompt = testTool.Contains("echo", StringComparison.OrdinalIgnoreCase) + ? "Use the echo tool to echo the message 'Hello from Gemini!'" + : testTool.Contains("add", StringComparison.OrdinalIgnoreCase) + ? "Use the add tool to add 5 and 7" + : testTool.Contains("time", StringComparison.OrdinalIgnoreCase) + ? "Use the time tool to get the current time" + : $"Call the {testTool} tool with appropriate parameters"; + + Console.WriteLine($"Prompt: {prompt}"); + + model.FunctionCallingBehaviour.AutoCallFunction = false; + var result = await model.GenerateContentAsync( + prompt, + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + result.ShouldNotBeNull(); + var responseText = result.Text(); + Console.WriteLine($"\n=== Gemini's Response ==="); + Console.WriteLine(responseText); + + // Check if function was called + if (result.Candidates?[0].Content?.Parts != null) + { + var functionCalls = result.Candidates[0].Content.Parts + .Where(p => p.FunctionCall != null) + .Select(p => p.FunctionCall) + .ToList(); + + if (functionCalls.Any()) + { + Console.WriteLine($"\n=== Function Calls Made ==="); + foreach (var call in functionCalls) + { + Console.WriteLine($"Function: {call?.Name}"); + Console.WriteLine($"Args: {call?.Args?.ToJsonString()}"); + } + } + } + + responseText.ShouldNotBeNullOrEmpty(); + } + else + { + Console.WriteLine("No suitable tool found for Gemini test"); + Console.WriteLine($"Available: {string.Join(", ", availableFunctions)}"); + } + } + + [Fact] + public async Task ShouldHandleComplexWorkflowWithMcp() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "everything-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + var model = new GenerativeModel(GetTestGooglePlatform(), GoogleAIModels.DefaultGeminiModel); + model.AddFunctionTool(mcpTool); + + // Act - Use a multi-turn conversation with function calling + var chat = model.StartChat(); + + Console.WriteLine("=== Starting Chat Session ==="); + + var response1 = await chat.GenerateContentAsync( + "What tools do you have available? Pick one and demonstrate how to use it.", + cancellationToken: TestContext.Current.CancellationToken + ); + + Console.WriteLine($"\n=== Turn 1 ==="); + Console.WriteLine(response1.Text()); + + // If there were function calls, the response should mention them + response1.ShouldNotBeNull(); + response1.Text().ShouldNotBeNullOrEmpty(); + + // Continue the conversation + var response2 = await chat.GenerateContentAsync( + "Great! Can you try using another tool?", + cancellationToken: TestContext.Current.CancellationToken + ); + + Console.WriteLine($"\n=== Turn 2 ==="); + Console.WriteLine(response2.Text()); + + response2.ShouldNotBeNull(); + response2.Text().ShouldNotBeNullOrEmpty(); + + // Verify chat history includes our messages + var history = chat.History; + history.Count.ShouldBeGreaterThanOrEqualTo(2); + + Console.WriteLine($"\n=== Chat History: {history.Count} entries ==="); + } +} diff --git a/tests/GenerativeAI.IntegrationTests/McpTool_Tests.cs b/tests/GenerativeAI.IntegrationTests/McpTool_Tests.cs new file mode 100644 index 0000000..6b01f8c --- /dev/null +++ b/tests/GenerativeAI.IntegrationTests/McpTool_Tests.cs @@ -0,0 +1,444 @@ +using System.Text.Json.Nodes; +using GenerativeAI.Tests; +using GenerativeAI.Tools.Mcp; +using GenerativeAI.Types; +using ModelContextProtocol.Client; +using Shouldly; +using Xunit; + +namespace GenerativeAI.IntegrationTests; + +/// +/// Integration tests for MCP (Model Context Protocol) tool integration. +/// These tests verify that McpTool correctly integrates MCP servers with Gemini models. +/// +public class McpTool_Tests : TestBase +{ + public McpTool_Tests(ITestOutputHelper helper) : base(helper) + { + } + + [Fact] + public void ShouldCreateStdioTransport() + { + // Arrange & Act + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "node", + arguments: new[] { "server.js" }, + workingDirectory: "/test/path" + ); + + // Assert + transport.ShouldNotBeNull(); + transport.ShouldBeOfType(); + transport.Name.ShouldBe("test-server"); + } + + [Fact] + public void ShouldCreateHttpTransport() + { + // Arrange & Act + var transport = McpTransportFactory.CreateHttpTransport( + baseUrl: "http://localhost:8080" + ); + + // Assert + transport.ShouldNotBeNull(); + transport.ShouldBeOfType(); + } + + [Fact] + public void ShouldCreateHttpTransportWithAuth() + { + // Arrange & Act + var transport = McpTransportFactory.CreateHttpTransportWithAuth( + baseUrl: "http://localhost:8080", + authToken: "test-token-123" + ); + + // Assert + transport.ShouldNotBeNull(); + transport.ShouldBeOfType(); + } + + [Fact] + public void ShouldCreateHttpTransportWithCustomHeaders() + { + // Arrange + var headers = new Dictionary + { + { "X-Custom-Header", "CustomValue" }, + { "X-API-Version", "v1" } + }; + + // Act + var transport = McpTransportFactory.CreateHttpTransportWithHeaders( + baseUrl: "http://localhost:8080", + headers: headers + ); + + // Assert + transport.ShouldNotBeNull(); + transport.ShouldBeOfType(); + } + + [Fact] + public void McpToolOptions_ShouldHaveDefaultValues() + { + // Arrange & Act + var options = new McpToolOptions(); + + // Assert + options.ConnectionTimeoutMs.ShouldBe(30000); + options.AutoReconnect.ShouldBeTrue(); + options.MaxReconnectAttempts.ShouldBe(3); + options.ThrowOnToolCallFailure.ShouldBeFalse(); + options.IncludeDetailedErrors.ShouldBeTrue(); + } + + [Fact] + public void McpToolOptions_ShouldAllowCustomization() + { + // Arrange & Act + var options = new McpToolOptions + { + ConnectionTimeoutMs = 60000, + AutoReconnect = false, + MaxReconnectAttempts = 5, + ThrowOnToolCallFailure = true, + IncludeDetailedErrors = false + }; + + // Assert + options.ConnectionTimeoutMs.ShouldBe(60000); + options.AutoReconnect.ShouldBeFalse(); + options.MaxReconnectAttempts.ShouldBe(5); + options.ThrowOnToolCallFailure.ShouldBeTrue(); + options.IncludeDetailedErrors.ShouldBeFalse(); + } + + // Note: The following tests require an actual MCP server running. + // They will attempt to launch npx @modelcontextprotocol/server-everything + + [Fact] + public async Task ShouldConnectToMcpServer_Stdio() + { + // This test requires a working MCP server that can be launched via stdio + // Example: npx @modelcontextprotocol/server-everything + + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + // Act + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + mcpTool.ShouldNotBeNull(); + mcpTool.IsConnected.ShouldBeTrue(); + mcpTool.Client.ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldDiscoverToolsFromMcpServer() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + // Act + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var availableFunctions = mcpTool.GetAvailableFunctions(); + + // Assert + availableFunctions.ShouldNotBeNull(); + availableFunctions.Count.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task ShouldGenerateToolDeclarationsFromMcpServer() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + // Act + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var tool = mcpTool.AsTool(); + + // Assert + tool.ShouldNotBeNull(); + tool.FunctionDeclarations.ShouldNotBeNull(); + tool.FunctionDeclarations.Count.ShouldBeGreaterThan(0); + + // Verify function declarations have required fields + foreach (var declaration in tool.FunctionDeclarations) + { + declaration.Name.ShouldNotBeNullOrEmpty(); + declaration.Description.ShouldNotBeNull(); + } + } + + [Fact] + public async Task ShouldCallMcpToolFunction() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var availableFunctions = mcpTool.GetAvailableFunctions(); + + // Assume the first function is available + availableFunctions.Count.ShouldBeGreaterThan(0); + var functionName = availableFunctions[0]; + + var functionCall = new FunctionCall + { + Name = functionName, + Args = new JsonObject() // Add appropriate args based on the function + }; + + // Act + var response = await mcpTool.CallAsync(functionCall, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + response.ShouldNotBeNull(); + response.Name.ShouldBe(functionName); + response.Response.ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldIntegrateWithGeminiModel() + { + Assert.SkipUnless(IsGoogleApiKeySet, GoogleTestSkipMessage); + + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "filesystem-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + + var model = new GenerativeModel(GetTestGooglePlatform(), GoogleAIModels.Gemini2Flash); + model.AddFunctionTool(mcpTool); + + // Act + var result = await model.GenerateContentAsync( + "What tools are available to you?", + cancellationToken: TestContext.Current.CancellationToken + ); + + // Assert + result.ShouldNotBeNull(); + result.Text().ShouldNotBeNullOrEmpty(); + Console.WriteLine($"Model response: {result.Text()}"); + } + + [Fact] + public async Task ShouldRefreshToolsFromServer() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var initialFunctions = mcpTool.GetAvailableFunctions(); + + // Act + await mcpTool.RefreshToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var refreshedFunctions = mcpTool.GetAvailableFunctions(); + + // Assert + refreshedFunctions.Count.ShouldBe(initialFunctions.Count); + } + + [Fact] + public async Task ShouldHandleConnectionWithTransportFactory() + { + // Arrange + var factory = () => McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + // Act + using var mcpTool = await McpTool.CreateAsync(factory, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + mcpTool.IsConnected.ShouldBeTrue(); + } + + [Fact] + public async Task ShouldCreateMultipleMcpToolsFromTransports() + { + // Arrange + var transports = new List + { + McpTransportFactory.CreateStdioTransport( + name: "server1", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ), + McpTransportFactory.CreateStdioTransport( + name: "server2", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ) + }; + + // Act + var mcpTools = await McpTool.CreateMultipleAsync(transports, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + mcpTools.Count.ShouldBe(2); + mcpTools[0].IsConnected.ShouldBeTrue(); + mcpTools[1].IsConnected.ShouldBeTrue(); + + // Cleanup + foreach (var tool in mcpTools) + { + await tool.DisposeAsync(); + } + } + + [Fact] + public async Task ShouldGetFunctionInfo() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + using var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + var availableFunctions = mcpTool.GetAvailableFunctions(); + + availableFunctions.Count.ShouldBeGreaterThan(0); + var functionName = availableFunctions[0]; + + // Act + var functionInfo = mcpTool.GetFunctionInfo(functionName); + + // Assert + functionInfo.ShouldNotBeNull(); + functionInfo.Name.ShouldBe(functionName); + functionInfo.Description.ShouldNotBeNull(); + } + + [Fact] + public async Task ShouldHandleErrorResponseGracefully() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + var options = new McpToolOptions + { + ThrowOnToolCallFailure = false, + IncludeDetailedErrors = true + }; + + using var mcpTool = await McpTool.CreateAsync(transport, options, cancellationToken: TestContext.Current.CancellationToken); + + var invalidCall = new FunctionCall + { + Name = "nonexistent_function", + Args = new JsonObject() + }; + + // Act & Assert + await Should.ThrowAsync(async () => + { + await mcpTool.CallAsync(invalidCall, cancellationToken: TestContext.Current.CancellationToken); + }); + } + + [Fact] + public async Task ShouldDisposeProperlyAsync() + { + // Arrange + var transport = McpTransportFactory.CreateStdioTransport( + name: "test-server", + command: "npx", + arguments: new[] { "-y", "@modelcontextprotocol/server-everything" } + ); + + var mcpTool = await McpTool.CreateAsync(transport, cancellationToken: TestContext.Current.CancellationToken); + mcpTool.IsConnected.ShouldBeTrue(); + + // Act + await mcpTool.DisposeAsync(); + + // Assert - after disposal, we can't really check IsConnected as client is disposed + // Just verify no exception is thrown + } + + [Fact] + public void ShouldVerifyMcpToolOptionsDefaults() + { + // This is a unit test that doesn't require an MCP server + var options = new McpToolOptions(); + + options.ConnectionTimeoutMs.ShouldBe(30000); + options.AutoReconnect.ShouldBeTrue(); + options.MaxReconnectAttempts.ShouldBe(3); + options.ThrowOnToolCallFailure.ShouldBeFalse(); + options.IncludeDetailedErrors.ShouldBeTrue(); + } + + [Fact] + public void ShouldCreateTransportFactoryMethods() + { + // Test that all factory methods create transports without throwing + + // Stdio transport + var stdioTransport = McpTransportFactory.CreateStdioTransport( + "test", + "node", + new[] { "server.js" } + ); + stdioTransport.ShouldNotBeNull(); + + // HTTP transport + var httpTransport = McpTransportFactory.CreateHttpTransport("http://localhost:8080"); + httpTransport.ShouldNotBeNull(); + + // HTTP with auth + var authTransport = McpTransportFactory.CreateHttpTransportWithAuth( + "http://localhost:8080", + "token123" + ); + authTransport.ShouldNotBeNull(); + + // HTTP with headers + var headersTransport = McpTransportFactory.CreateHttpTransportWithHeaders( + "http://localhost:8080", + new Dictionary { { "X-Test", "value" } } + ); + headersTransport.ShouldNotBeNull(); + } +}