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
1 change: 1 addition & 0 deletions ModelContextProtocol.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
<Project Path="samples/AspNetCoreMcpPerSessionTools/AspNetCoreMcpPerSessionTools.csproj" />
<Project Path="samples/AspNetCoreMcpServer/AspNetCoreMcpServer.csproj" />
<Project Path="samples/ChatWithTools/ChatWithTools.csproj" />
<Project Path="samples/DependencyInjectionClient/DependencyInjectionClient.csproj" />
<Project Path="samples/EverythingServer/EverythingServer.csproj" />
<Project Path="samples/InMemoryTransport/InMemoryTransport.csproj" />
<Project Path="samples/LongRunningTasks/LongRunningTasks.csproj" />
Expand Down
49 changes: 49 additions & 0 deletions docs/concepts/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,55 @@ Console.WriteLine(result.Content.OfType<TextContentBlock>().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<ILoggerFactory>();
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<MyWorker>();
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<string, string>
{
["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`:
Expand Down
18 changes: 18 additions & 0 deletions samples/DependencyInjectionClient/DependencyInjectionClient.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" />
</ItemGroup>

</Project>
65 changes: 65 additions & 0 deletions samples/DependencyInjectionClient/Program.cs
Original file line number Diff line number Diff line change
@@ -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<ILoggerFactory>();

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<McpWorker>();

var host = builder.Build();
await host.RunAsync();

/// <summary>
/// A background service that demonstrates using an injected MCP client.
/// </summary>
sealed class McpWorker(McpClient mcpClient, ILogger<McpWorker> 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<string, object?> { ["message"] = "Hello from DI!" },
cancellationToken: stoppingToken);

var text = result.Content.OfType<TextContentBlock>().FirstOrDefault()?.Text;
logger.LogInformation("Echo result: {Result}", text);

// Shut down after the demo completes.
lifetime.StopApplication();
}
}
13 changes: 13 additions & 0 deletions src/ModelContextProtocol.Core/Authentication/ClientOAuthOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -103,4 +103,17 @@ public sealed class ClientOAuthOptions
/// If none is provided, tokens will be cached with the transport.
/// </summary>
public ITokenCache? TokenCache { get; set; }

/// <summary>
/// Gets or sets a value indicating whether to include the <c>resource</c> parameter in OAuth authorization
/// and token requests as defined by <see href="https://datatracker.ietf.org/doc/rfc8707/">RFC 8707</see>.
/// </summary>
/// <remarks>
/// <para>
/// The default value is <see langword="true"/>. Set to <see langword="false"/> when using an OAuth provider
/// that does not support the <c>resource</c> parameter, such as Microsoft Entra ID (Azure AD v2.0),
/// which returns error <c>AADSTS901002</c> when the parameter is present.
/// </para>
/// </remarks>
public bool IncludeResourceIndicator { get; set; } = true;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -90,6 +91,7 @@ public ClientOAuthProvider(
_dcrInitialAccessToken = options.DynamicClientRegistration?.InitialAccessToken;
_dcrResponseDelegate = options.DynamicClientRegistration?.ResponseDelegate;
_tokenCache = options.TokenCache ?? new InMemoryTokenCache();
_includeResourceIndicator = options.IncludeResourceIndicator;
}

/// <summary>
Expand Down Expand Up @@ -154,7 +156,7 @@ internal override async Task<HttpResponseMessage> 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);
}

Expand Down Expand Up @@ -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)
{
Expand Down
143 changes: 143 additions & 0 deletions tests/ModelContextProtocol.AspNetCore.Tests/OAuth/AuthTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?>(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<McpException>(() => 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<JwtBearerOptions>(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<JwtBearerOptions>(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.");
}
}