diff --git a/ModelContextProtocol.slnx b/ModelContextProtocol.slnx index 1090c5377..a30a78b28 100644 --- a/ModelContextProtocol.slnx +++ b/ModelContextProtocol.slnx @@ -42,6 +42,7 @@ + diff --git a/docs/concepts/getting-started.md b/docs/concepts/getting-started.md index 938f57501..3152aafce 100644 --- a/docs/concepts/getting-started.md +++ b/docs/concepts/getting-started.md @@ -133,6 +133,55 @@ Console.WriteLine(result.Content.OfType().First().Text); Clients can connect to any MCP server, not just ones created with this library. The protocol is server-agnostic. +#### Using dependency injection with MCP clients + +To use an MCP client in an application with dependency injection, register it as a singleton service and consume it from hosted services or other DI-managed components: + +```csharp +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Services.AddSingleton(sp => +{ + var loggerFactory = sp.GetRequiredService(); + var transport = new StdioClientTransport(new() + { + Name = "My Server", + Command = "dotnet", + Arguments = ["run", "--project", "path/to/server"], + }); + // Note: .GetAwaiter().GetResult() is used here for simplicity in console apps. + // In ASP.NET Core or other environments with a SynchronizationContext, + // use an IHostedService to initialize async resources instead. + return McpClient.CreateAsync(transport, loggerFactory: loggerFactory) + .GetAwaiter().GetResult(); +}); + +builder.Services.AddHostedService(); +await builder.Build().RunAsync(); +``` + +For Docker-based MCP servers, configure the transport with `docker run`: + +```csharp +var transport = new StdioClientTransport(new() +{ + Name = "my-mcp-server", + Command = "docker", + Arguments = ["run", "-i", "--rm", "mcp/my-server"], + EnvironmentVariables = new Dictionary + { + ["API_KEY"] = configuration["ApiKey"]!, + }, +}); +``` + +See the [`DependencyInjectionClient`](https://github.com/modelcontextprotocol/csharp-sdk/tree/main/samples/DependencyInjectionClient) sample for a complete example. + #### Using tools with an LLM `McpClientTool` inherits from `AIFunction`, so the tools returned by `ListToolsAsync` can be handed directly to any `IChatClient`: diff --git a/samples/DependencyInjectionClient/DependencyInjectionClient.csproj b/samples/DependencyInjectionClient/DependencyInjectionClient.csproj new file mode 100644 index 000000000..e12d5d19f --- /dev/null +++ b/samples/DependencyInjectionClient/DependencyInjectionClient.csproj @@ -0,0 +1,18 @@ + + + + Exe + net8.0 + enable + enable + + + + + + + + + + + diff --git a/samples/DependencyInjectionClient/Program.cs b/samples/DependencyInjectionClient/Program.cs new file mode 100644 index 000000000..41e29c870 --- /dev/null +++ b/samples/DependencyInjectionClient/Program.cs @@ -0,0 +1,65 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; + +// This sample demonstrates how to wire up MCP clients with dependency injection +// using Microsoft.Extensions.Hosting and IServiceCollection. + +var builder = Host.CreateApplicationBuilder(args); + +// Register an MCP client as a singleton. +// The factory method creates the client with a StdioClientTransport. +// Replace the command/arguments with your own MCP server (e.g., a Docker container). +builder.Services.AddSingleton(sp => +{ + var loggerFactory = sp.GetRequiredService(); + + var transport = new StdioClientTransport(new() + { + Name = "Everything", + Command = "npx", + Arguments = ["-y", "@modelcontextprotocol/server-everything"], + }); + + // McpClient.CreateAsync is async; we block here for DI registration. + // In production, consider using an IHostedService to initialize async resources. + return McpClient.CreateAsync(transport, loggerFactory: loggerFactory) + .GetAwaiter().GetResult(); +}); + +// Register a hosted service that uses the MCP client. +builder.Services.AddHostedService(); + +var host = builder.Build(); +await host.RunAsync(); + +/// +/// A background service that demonstrates using an injected MCP client. +/// +sealed class McpWorker(McpClient mcpClient, ILogger logger, IHostApplicationLifetime lifetime) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + // List available tools from the MCP server. + var tools = await mcpClient.ListToolsAsync(cancellationToken: stoppingToken); + logger.LogInformation("Available tools ({Count}):", tools.Count); + foreach (var tool in tools) + { + logger.LogInformation(" {Name}: {Description}", tool.Name, tool.Description); + } + + // Invoke a tool. + var result = await mcpClient.CallToolAsync( + "echo", + new Dictionary { ["message"] = "Hello from DI!" }, + cancellationToken: stoppingToken); + + var text = result.Content.OfType().FirstOrDefault()?.Text; + logger.LogInformation("Echo result: {Result}", text); + + // Shut down after the demo completes. + lifetime.StopApplication(); + } +} diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs index 483e3643e..74764a304 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs @@ -103,4 +103,17 @@ public sealed class ClientOAuthOptions /// If none is provided, tokens will be cached with the transport. /// public ITokenCache? TokenCache { get; set; } + + /// + /// Gets or sets a value indicating whether to include the resource parameter in OAuth authorization + /// and token requests as defined by RFC 8707. + /// + /// + /// + /// The default value is . Set to when using an OAuth provider + /// that does not support the resource parameter, such as Microsoft Entra ID (Azure AD v2.0), + /// which returns error AADSTS901002 when the parameter is present. + /// + /// + public bool IncludeResourceIndicator { get; set; } = true; } diff --git a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs index ecef8e15e..0dbf24cad 100644 --- a/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs +++ b/src/ModelContextProtocol.Core/Authentication/ClientOAuthProvider.cs @@ -41,6 +41,7 @@ internal sealed partial class ClientOAuthProvider : McpHttpClient private readonly HttpClient _httpClient; private readonly ILogger _logger; + private readonly bool _includeResourceIndicator; private string? _clientId; private string? _clientSecret; @@ -90,6 +91,7 @@ public ClientOAuthProvider( _dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken; _dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate; _tokenCache = options.TokenCache ?? new InMemoryTokenCache(); + _includeResourceIndicator = options.IncludeResourceIndicator; } /// @@ -154,7 +156,7 @@ internal override async Task SendAsync(HttpRequestMessage r // Try to refresh the access token if it is invalid and we have a refresh token. if (_authServerMetadata is not null && tokens?.RefreshToken is { Length: > 0 } refreshToken) { - var accessToken = await RefreshTokensAsync(refreshToken, resourceUri.ToString(), _authServerMetadata, cancellationToken).ConfigureAwait(false); + var accessToken = await RefreshTokensAsync(refreshToken, _includeResourceIndicator ? resourceUri.ToString() : null, _authServerMetadata, cancellationToken).ConfigureAwait(false); return (accessToken, true); } @@ -709,8 +711,8 @@ private async Task PerformDynamicClientRegistrationAsync( } } - private static string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) - => protectedResourceMetadata.Resource; + private string? GetResourceUri(ProtectedResourceMetadata protectedResourceMetadata) + => _includeResourceIndicator ? protectedResourceMetadata.Resource : null; private string? GetScopeParameter(ProtectedResourceMetadata protectedResourceMetadata) { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs index c4979fb10..ec52e854f 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs @@ -1261,4 +1261,147 @@ public async Task CanAuthenticate_WithLegacyServerUsingDefaultEndpointFallback() await using var client = await McpClient.CreateAsync( transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); } + + [Fact] + public async Task CanAuthenticate_WithoutResourceIndicator() + { + await using var app = await StartMcpServerAsync(); + + Uri? capturedAuthorizationUrl = null; + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + IncludeResourceIndicator = false, + AuthorizationRedirectDelegate = (authorizationUri, redirectUri, cancellationToken) => + { + capturedAuthorizationUrl = authorizationUri; + // Return null to signal that authorization was not completed. + return Task.FromResult(null); + }, + }, + }, HttpClient, LoggerFactory); + + // The auth flow will fail because we return null from the delegate, + // but we only need to verify the authorization URL was constructed correctly. + await Assert.ThrowsAsync(() => McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken)); + + Assert.NotNull(capturedAuthorizationUrl); + var query = QueryHelpers.ParseQuery(capturedAuthorizationUrl.Query); + Assert.False(query.ContainsKey("resource"), "The 'resource' query parameter should not be present when IncludeResourceIndicator is false."); + Assert.True(query.ContainsKey("scope"), "The 'scope' query parameter should still be present."); + } + + [Fact] + public async Task CanAuthenticate_WithoutResourceIndicator_EndToEnd() + { + // Simulate an Entra ID-like server that rejects the 'resource' parameter. + TestOAuthServer.ExpectResource = false; + + // Without resource indicator the token audience falls back to the client ID, + // matching real Entra ID behavior. Configure the server to accept it. + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters.ValidAudiences = [McpServerUrl, "demo-client"]; + }); + + await using var app = await StartMcpServerAsync(); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + IncludeResourceIndicator = false, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + // This would fail with "invalid_target" if the resource parameter leaked through + // in either the authorization, token exchange, or silent refresh paths. + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task CanAuthenticate_WithoutResourceIndicator_TokenRefresh() + { + // Simulate an Entra ID-like server that rejects the 'resource' parameter. + TestOAuthServer.ExpectResource = false; + + var hasForcedRefresh = false; + + Builder.Services.AddMcpServer(options => + { + options.ToolCollection = new(); + }); + + // Without resource indicator the token audience falls back to the client ID. + Builder.Services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.TokenValidationParameters.ValidAudiences = [McpServerUrl, "demo-client"]; + }); + + await using var app = await StartMcpServerAsync(configureMiddleware: app => + { + app.Use(async (context, next) => + { + if (context.Request.Method == HttpMethods.Post && context.Request.Path == "/" && !hasForcedRefresh) + { + context.Request.EnableBuffering(); + + var message = await JsonSerializer.DeserializeAsync( + context.Request.Body, + McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(JsonRpcMessage)), + context.RequestAborted) as JsonRpcMessage; + + context.Request.Body.Position = 0; + + if (message is JsonRpcRequest request && request.Method == "tools/list") + { + hasForcedRefresh = true; + + // Return 401 to force token refresh + await context.ChallengeAsync(JwtBearerDefaults.AuthenticationScheme); + await context.Response.StartAsync(context.RequestAborted); + await context.Response.Body.FlushAsync(context.RequestAborted); + return; + } + } + + await next(context); + }); + }); + + await using var transport = new HttpClientTransport(new() + { + Endpoint = new(McpServerUrl), + OAuth = new() + { + ClientId = "demo-client", + ClientSecret = "demo-secret", + RedirectUri = new Uri("http://localhost:1179/callback"), + IncludeResourceIndicator = false, + AuthorizationRedirectDelegate = HandleAuthorizationUrlAsync, + }, + }, HttpClient, LoggerFactory); + + await using var client = await McpClient.CreateAsync( + transport, loggerFactory: LoggerFactory, cancellationToken: TestContext.Current.CancellationToken); + + // This triggers the 401 → token refresh path. If the resource parameter + // leaks into the refresh request, the mock Entra ID server returns invalid_target. + await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + Assert.True(TestOAuthServer.HasRefreshedToken, "Token refresh should have occurred."); + } }