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.");
+ }
}