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
54 changes: 54 additions & 0 deletions docs/decisions/0055-dotnet-declarative-mcp-allowed-hosts.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
---
status: proposed
contact: baywet
date: 2026-11-09
deciders: westey-m
---

# Explicit allowed hosts for declarative MCP invocations

## Context and Problem Statement

Declarative MCP actions receive their server URLs from workflow content. The application
owns destination configuration and the HTTP pipeline. The default MCP handler should apply
the same host-and-port policy as the [default HTTP handler](0049-dotnet-declarative-http-allowed-hosts.md).

## Decision Drivers

- Require application-configured destinations before creating an HTTP client.
- Keep host normalization and port matching consistent between HTTP and MCP.
- Make destination and transport configuration explicit while preserving per-invocation session lifetime.

## Considered Options

- Permit all destinations: preserves current behavior, but provides no application-owned destination policy.
- Require a new constructor signature: makes configuration explicit, but removes an existing API.
- Retain the existing constructor with an empty policy and add an allowlist overload: preserves binary
compatibility, but applications must migrate their configuration to continue invoking tools.

## Decision Outcome

Chosen option: require a new constructor signature with allowed hosts and an `IHttpMessageHandlerFactory`.
Remove the constructor accepting `IHttpClientFactory` rather than retain an overload that cannot invoke tools.

The application supplies exact hosts or IP literals with optional ports. The list is copied, an omitted
port defaults to 443, and an empty list denies every invocation, including discovery. Matching uses the
same shared normalization as HTTP: case-insensitive IDN names, trailing DNS dots removed, and exact
effective ports, without wildcards or implicit subdomains. Workflow-supplied Host headers are rejected.

Both HTTP and HTTPS remain supported. Applications requiring HTTPS must enforce that additional policy.
DNS, proxy configuration, and routing remain transport responsibilities. The application supplies an
`IHttpMessageHandlerFactory`; before sending, the handler validates that each pipeline terminates in an
`HttpClientHandler`, or on .NET a `SocketsHttpHandler`, with cookies and automatic redirects disabled.
Unknown primary transports and incomplete pipelines are rejected. Redirect responses are not followed.
Application-owned delegating handlers must preserve the validated destination and bind credentials to
the intended origin.

The handler creates and disposes an HTTP client per invocation without disposing the factory-owned
pipeline. Client configuration callbacks registered through `IHttpClientFactory` do not apply; configure
headers and origin-bound authentication in delegating handlers. This matches the HTTP handler's ownership
and transport-validation contract while keeping MCP protocol sessions separate per invocation.

This removes the existing constructor. Callers must migrate to the constructor accepting their intended
destinations and a message handler factory; applications with different policies can provide an `IMcpToolHandler`.
Session creation, protocol selection, and resource disposal remain unchanged.
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ public static async Task Main(string[] args)
.SetHandlerLifetime(TimeSpan.FromMinutes(2));
await using ServiceProvider serviceProvider = services.BuildServiceProvider();
DefaultMcpToolHandler mcpToolHandler = new(
serviceProvider.GetRequiredService<IHttpClientFactory>(), McpHttpClientName);
[new Uri(toolboxMcpServerUrl).Authority],
serviceProvider.GetRequiredService<IHttpMessageHandlerFactory>(), McpHttpClientName);

// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeFoundryToolboxMcp.yaml", foundryEndpoint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ public static async Task Main(string[] args)
.SetHandlerLifetime(TimeSpan.FromMinutes(2));
await using ServiceProvider serviceProvider = services.BuildServiceProvider();
DefaultMcpToolHandler mcpToolHandler = new(
serviceProvider.GetRequiredService<IHttpClientFactory>(), McpHttpClientName);
["learn.microsoft.com", "mcp.ai.azure.com"],
serviceProvider.GetRequiredService<IHttpMessageHandlerFactory>(), McpHttpClientName);

// Create the workflow factory with MCP tool provider
WorkflowFactory workflowFactory = new("InvokeMcpTool.yaml", foundryEndpoint)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp;
/// </summary>
/// <remarks>
/// Every invocation creates and disposes a separate MCP client, including <c>tools/list</c>.
/// Factory-created HTTP clients reuse factory-managed handlers without sharing MCP session state.
/// Per-invocation HTTP clients reuse factory-managed handlers without sharing MCP session state.
/// Standalone GET streams for unsolicited server messages are disabled; request-scoped SSE responses remain supported.
/// The handler owns no shared disposable resources. The host must await its invocations before disposing
/// the application-owned factory infrastructure.
Expand All @@ -42,8 +42,9 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler

private static readonly JsonWriterOptions s_toolListJsonWriterOptions = new() { Indented = true };

private readonly IHttpClientFactory _httpClientFactory;
private readonly IHttpMessageHandlerFactory _httpMessageHandlerFactory;
private readonly string _httpClientName;
private readonly AllowedHttpHosts _allowedHosts;

/// <summary>
/// Gets or initializes the protocol version requested for every invocation.
Expand All @@ -60,27 +61,34 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler
public string? ProtocolVersion { get; init; }

/// <summary>
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class using an application-provided HTTP client factory.
/// Initializes a new instance of the <see cref="DefaultMcpToolHandler"/> class with allowed hosts
/// and an application-provided HTTP message handler factory.
/// </summary>
/// <param name="httpClientFactory">
/// The factory that creates a separate <see cref="HttpClient"/> for every invocation.
/// Factory-created clients are disposed after MCP cleanup; the factory and its pooled handlers remain application-owned.
/// <param name="allowedHosts">
/// The exact DNS names or IP literals, optionally including a port, the application permits.
/// Omitted ports default to 443. Use brackets around IPv6 literals when specifying a port.
/// Schemes, paths, and wildcards are not supported. An empty collection rejects all invocations.
/// The collection is copied and checked before creating an HTTP client, including for tool discovery.
/// Matching is case-insensitive, with internationalized names and trailing DNS dots normalized.
/// Both HTTP and HTTPS are supported; authorization matches the host and effective port, not the scheme.
/// DNS resolution and routing remain the responsibility of the HTTP transport.
/// </param>
/// <param name="httpClientName">
/// The stable named-client configuration to use, or an empty string for the default configuration.
/// Do not derive names from workflow identifiers, credentials, or arbitrary server URLs.
/// <param name="httpMessageHandlerFactory">
/// The application-owned factory supplying pooled HTTP handler pipelines. The primary handler must be
/// an <see cref="HttpClientHandler"/> or, on .NET, a <c>SocketsHttpHandler</c>, with cookies and automatic
/// redirects disabled. Enable certificate revocation checking and bind credentials to the intended origin
/// in delegating handlers. Delegating handlers must preserve the validated destination.
/// Per-invocation HTTP clients do not dispose factory-owned pipelines. Client configuration registered
/// through <see cref="IHttpClientFactory"/> does not apply; configure headers in delegating handlers.
/// </param>
/// <remarks>
/// The application is responsible for the HTTP pipeline's transport policy.
/// Disable cookies and automatic redirects, enable certificate revocation checking, and restrict credential injection
/// to the configured server origin. Pooled handlers must not retain per-invocation credentials or mutable origin state.
/// Configure a finite handler lifetime so new invocations can pick up endpoint changes without disrupting active calls.
/// </remarks>
/// <param name="httpClientName">The stable named-client configuration, or an empty string for the default configuration.</param>
/// <exception cref="ArgumentNullException">A required argument is <see langword="null"/>.</exception>
public DefaultMcpToolHandler(IHttpClientFactory httpClientFactory, string httpClientName)
/// <exception cref="ArgumentException">An allowed host is invalid.</exception>
public DefaultMcpToolHandler(IEnumerable<string> allowedHosts, IHttpMessageHandlerFactory httpMessageHandlerFactory, string httpClientName)
Comment thread
baywet marked this conversation as resolved.
Resolved
{
this._httpClientFactory = Throw.IfNull(httpClientFactory);
this._httpMessageHandlerFactory = Throw.IfNull(httpMessageHandlerFactory);
this._httpClientName = Throw.IfNull(httpClientName);
this._allowedHosts = new(allowedHosts);
}

/// <inheritdoc/>
Expand Down Expand Up @@ -110,6 +118,10 @@ public async Task<McpServerToolResultContent> InvokeToolAsync(
header.Value,
nameof(headers),
"HTTP header value contains invalid characters.");
if (string.Equals(header.Key, "Host", StringComparison.OrdinalIgnoreCase))
{
throw new HttpRequestException("Request headers must not override the Host header.");
}
}
}

Expand Down Expand Up @@ -189,7 +201,14 @@ private async Task<ClientConnection> CreateClientAsync(
EnableStandaloneGetStream = false
};

HttpClient httpClient = this._httpClientFactory.CreateClient(this._httpClientName);
if (!this._allowedHosts.Contains(endpoint))
Comment thread
baywet marked this conversation as resolved.
Resolved
{
throw new HttpRequestException("The request host and port are not in the allowed hosts list.");
}

HttpMessageHandler messageHandler = this._httpMessageHandlerFactory.CreateHandler(this._httpClientName);
HttpTransportValidation.Validate(messageHandler);
HttpClient httpClient = new(messageHandler, disposeHandler: false);
HttpClientTransport? transport = null;
try
{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Net.Http.IHttpClientFactory! httpClientFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Collections.Generic.IEnumerable<string!>! allowedHosts, System.Net.Http.IHttpMessageHandlerFactory! httpMessageHandlerFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.get -> string?
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.init -> void
*REMOVED*Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Func<string!, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpClient?>!>? httpClientProvider = null) -> void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Net.Http.IHttpClientFactory! httpClientFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Collections.Generic.IEnumerable<string!>! allowedHosts, System.Net.Http.IHttpMessageHandlerFactory! httpMessageHandlerFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.get -> string?
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.init -> void
*REMOVED*Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Func<string!, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpClient?>!>? httpClientProvider = null) -> void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Net.Http.IHttpClientFactory! httpClientFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Collections.Generic.IEnumerable<string!>! allowedHosts, System.Net.Http.IHttpMessageHandlerFactory! httpMessageHandlerFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.get -> string?
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.init -> void
*REMOVED*Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Func<string!, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpClient?>!>? httpClientProvider = null) -> void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Net.Http.IHttpClientFactory! httpClientFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Collections.Generic.IEnumerable<string!>! allowedHosts, System.Net.Http.IHttpMessageHandlerFactory! httpMessageHandlerFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.get -> string?
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.init -> void
*REMOVED*Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Func<string!, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpClient?>!>? httpClientProvider = null) -> void
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
#nullable enable
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Net.Http.IHttpClientFactory! httpClientFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Collections.Generic.IEnumerable<string!>! allowedHosts, System.Net.Http.IHttpMessageHandlerFactory! httpMessageHandlerFactory, string! httpClientName) -> void
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.get -> string?
Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.ProtocolVersion.init -> void
*REMOVED*Microsoft.Agents.AI.Workflows.Declarative.Mcp.DefaultMcpToolHandler.DefaultMcpToolHandler(System.Func<string!, System.Threading.CancellationToken, System.Threading.Tasks.Task<System.Net.Http.HttpClient?>!>? httpClientProvider = null) -> void
Expand Down
Loading
Loading