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;
Copy link
Contributor

@halter73 halter73 Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Configuring this to false is insecure for MCP apps. We're required by the spec to send the resource parameter because of this.

See #940 (comment) for more context.

MCP servers are different than most HTTP servers using OAuth authorization code flow that can rely on TLS and limiting redirect URIs to ensure authorization codes and ultimately access tokens don't get sent to the wrong HTTP server (which is the OAuth client in the scenario). MCP uses the authorization code flow to send the authorization code to a non-first-party, non-browser client. Without a resource parameter, neither Entra nor any other OAuth server have any way to validate that the token will be sent to the expected MCP server. And your MCP server has no way to validate that the access token was sent directly to it rather than main-in-the-middled (MITM'd).

The lack of a resource parameter would make it trivial for another MCP server to copy your Protected Resource Metadata document and then MITM your MCP server. All the attacker would need to do is convince someone to enter the wrong URL into their MCP config while protending to be your server. The end-user's browser will happily autofill their credentials as they go to the real OAuth provider's website even though the resulting access token will ultimately be sent to the attackers MCP server first by the non-conforming MCP client that does not send the resource parameter.

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