diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/HostApplicationBuilder.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/HostApplicationBuilder.cs index faa1bbd0..5abf8dec 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/HostApplicationBuilder.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/Extensions/HostApplicationBuilder.cs @@ -16,6 +16,14 @@ public static IHostApplicationBuilder AddTeamsMcp(this IHostApplicationBuilder b return builder.AddTeamsPlugin(); } + public static IHostApplicationBuilder AddTeamsMcp(this IHostApplicationBuilder builder, Action configure) + { + var pluginOptions = new McpPluginOptions(); + configure(pluginOptions); + builder.Services.AddTeamsPlugin(new McpPlugin(pluginOptions)); + return builder; + } + public static IMcpServerBuilder AddTeamsMcp(this IHostApplicationBuilder builder, McpServerOptions options) { builder.AddTeamsPlugin(); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPlugin.cs index eddc04ea..a848f1c2 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPlugin.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Http; using Microsoft.Teams.Apps; using Microsoft.Teams.Apps.Events; using Microsoft.Teams.Apps.Plugins; @@ -19,16 +20,60 @@ namespace Microsoft.Teams.Plugins.External.Mcp; )] public class McpPlugin : IAspNetCorePlugin { + internal const string McpPath = "/mcp"; + [AllowNull] [Dependency] public ILogger Logger { get; set; } public event EventFunction Events; + private readonly McpPluginOptions _options; + + public McpPlugin() : this(new McpPluginOptions()) { } + + public McpPlugin(McpPluginOptions options) + { + _options = options; + } + public IApplicationBuilder Configure(IApplicationBuilder builder) { builder.UseRouting(); - return builder.UseEndpoints(endpoints => endpoints.MapMcp("mcp")); + + if (_options.RequireAuth is not null) + { + Func> requireAuth = _options.RequireAuth; + builder.Use(async (ctx, next) => + { + if (!ctx.Request.Path.StartsWithSegments(McpPath)) + { + await next(); + return; + } + + bool ok = false; + try + { + ok = await requireAuth(ctx); + } + catch (Exception ex) + { + Logger.Debug($"RequireAuth threw: {ex}"); + } + + if (!ok) + { + ctx.Response.StatusCode = 401; + await ctx.Response.WriteAsync("unauthorized"); + return; + } + + await next(); + }); + } + + return builder.UseEndpoints(endpoints => endpoints.MapMcp(McpPath.TrimStart('/'))); } public Task OnInit(App app, CancellationToken cancellationToken = default) @@ -38,6 +83,13 @@ public Task OnInit(App app, CancellationToken cancellationToken = default) public Task OnStart(App app, CancellationToken cancellationToken = default) { + if (_options.RequireAuth is null) + { + Logger.Warn( + $"McpPlugin started without RequireAuth. All MCP requests at {McpPath} will be accepted. " + + "Pass RequireAuth via AddTeamsMcp(options => options.RequireAuth = ...) to enforce authentication." + ); + } Logger.Debug("OnStart"); return Task.CompletedTask; } diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPluginOptions.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPluginOptions.cs new file mode 100644 index 00000000..2a0b9890 --- /dev/null +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.Mcp/McpPluginOptions.cs @@ -0,0 +1,17 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using Microsoft.AspNetCore.Http; + +namespace Microsoft.Teams.Plugins.External.Mcp; + +public class McpPluginOptions +{ + /// + /// Optional callback that gates inbound MCP requests. Return true to + /// allow the request; return false or throw to reject with HTTP 401. + /// When unset, all MCP requests are accepted and a warning is emitted at + /// plugin startup. + /// + public Func>? RequireAuth { get; set; } +} \ No newline at end of file diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs index 2e8177ef..b2f60ec0 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPlugin.cs @@ -181,6 +181,7 @@ internal async Task FetchToolsIfNeeded() internal async Task> FetchToolsFromServer(Uri url, McpClientPluginParams pluginParams) { + await UrlValidation.ValidateMcpServerUrlAsync(url, pluginParams.AllowPrivateNetwork, pluginParams.ValidateUrl); IClientTransport transport = CreateTransport(url, pluginParams.Transport, pluginParams.HeadersFactory()); var client = await McpClientFactory.CreateAsync(transport); var tools = await client.ListToolsAsync(); @@ -241,6 +242,7 @@ internal AI.Function CreateFunctionFromTool(Uri url, McpToolDetails tool, McpCli internal async Task CallMcpTool(Uri url, McpToolDetails tool, IReadOnlyDictionary args, McpClientPluginParams pluginParams) { + await UrlValidation.ValidateMcpServerUrlAsync(url, pluginParams.AllowPrivateNetwork, pluginParams.ValidateUrl); IClientTransport transport = CreateTransport(url, pluginParams.Transport, pluginParams.HeadersFactory()); var client = await McpClientFactory.CreateAsync(transport); var response = await client.CallToolAsync(tool.Name, args); diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPluginParams.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPluginParams.cs index 6a9fbaf6..1d1cf939 100644 --- a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPluginParams.cs +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/McpClientPluginParams.cs @@ -26,6 +26,20 @@ public class McpClientPluginParams /// Override default cache timeout of 1 day /// public int? RefetchTimeoutMs { get; set; } + + /// + /// When true, skip the default private-network filter and allow MCP server + /// URLs that resolve to loopback, RFC1918, or link-local addresses. Use for + /// local development or intentional on-prem MCP servers. + /// + public bool AllowPrivateNetwork { get; set; } = false; + + /// + /// Fully replace the default URL validation. When set, the callback decides + /// whether the URL is allowed; the default scheme and private-network checks + /// are skipped. + /// + public Func>? ValidateUrl { get; set; } } public enum McpClientTransport diff --git a/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/UrlValidation.cs b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/UrlValidation.cs new file mode 100644 index 00000000..6345442e --- /dev/null +++ b/Libraries/Microsoft.Teams.Plugins/Microsoft.Teams.Plugins.External/Microsoft.Teams.Plugins.External.McpClient/UrlValidation.cs @@ -0,0 +1,159 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; +using System.Net.Sockets; + +namespace Microsoft.Teams.Plugins.External.McpClient; + +public class UrlValidationException : Exception +{ + public UrlValidationException(string message) : base(message) { } + public UrlValidationException(string message, Exception inner) : base(message, inner) { } +} + +public static class UrlValidation +{ + /// + /// Test seam: override to mock DNS lookups. Defaults to . + /// + internal static Func> HostResolver { get; set; } = + (host, ct) => Dns.GetHostAddressesAsync(host, ct); + + /// + /// Validates a URL destined for an MCP server connection. When + /// is provided, it fully replaces the default + /// checks. Otherwise the default policy rejects non-http(s) schemes, and + /// (unless is true) rejects + /// URLs whose hostname resolves to a private / loopback / link-local address. + /// + /// Thrown on rejection. + public static async Task ValidateMcpServerUrlAsync( + Uri url, + bool allowPrivateNetwork = false, + Func>? validateUrl = null, + CancellationToken cancellationToken = default) + { + if (validateUrl is not null) + { + bool allowed = await validateUrl(url); + if (!allowed) + { + throw new UrlValidationException($"URL rejected by ValidateUrl: {url}"); + } + return url; + } + + if (url.Scheme != Uri.UriSchemeHttp && url.Scheme != Uri.UriSchemeHttps) + { + throw new UrlValidationException( + $"URL scheme {url.Scheme} is not allowed; must be http or https" + ); + } + + if (allowPrivateNetwork) + { + return url; + } + + IPAddress[] addresses; + if (IPAddress.TryParse(url.Host, out var literal)) + { + addresses = new[] { literal }; + } + else + { + try + { + addresses = await HostResolver(url.Host, cancellationToken); + } + catch (SocketException ex) + { + throw new UrlValidationException( + $"Could not resolve host {url.Host}: {ex.Message}", ex + ); + } + } + + if (addresses.Length == 0) + { + throw new UrlValidationException($"URL {url} did not resolve to any address"); + } + + foreach (var address in addresses) + { + if (IsPrivateAddress(address)) + { + throw new UrlValidationException( + $"URL {url} resolves to private or loopback address {address}; " + + "set AllowPrivateNetwork to true to bypass" + ); + } + } + + return url; + } + + /// + /// True if the address is loopback, RFC1918 private, link-local, or an + /// IPv6 unique-local / link-local address. + /// + public static bool IsPrivateAddress(IPAddress address) + { + if (IPAddress.IsLoopback(address)) return true; + if (IsUnspecified(address)) return true; + + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + if (address.IsIPv6LinkLocal) return true; + if (address.IsIPv6SiteLocal) return true; + if (IsIPv6UniqueLocal(address)) return true; + if (address.IsIPv4MappedToIPv6) + { + return IsPrivateIpv4(address.MapToIPv4()); + } + return false; + } + + if (address.AddressFamily == AddressFamily.InterNetwork) + { + return IsPrivateIpv4(address); + } + + // Unknown address family: fail closed. + return true; + } + + private static bool IsPrivateIpv4(IPAddress address) + { + var bytes = address.GetAddressBytes(); + if (bytes.Length != 4) return false; + + // 10.0.0.0/8 + if (bytes[0] == 10) return true; + // 172.16.0.0/12 + if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; + // 192.168.0.0/16 + if (bytes[0] == 192 && bytes[1] == 168) return true; + // 169.254.0.0/16 link-local + if (bytes[0] == 169 && bytes[1] == 254) return true; + return false; + } + + private static bool IsIPv6UniqueLocal(IPAddress address) + { + // fc00::/7 -> first byte is 0xfc or 0xfd + var bytes = address.GetAddressBytes(); + return bytes.Length == 16 && (bytes[0] == 0xfc || bytes[0] == 0xfd); + } + + private static bool IsUnspecified(IPAddress address) + { + // 0.0.0.0 (IPv4) or :: (IPv6) — no realistic MCP server binds here. + foreach (var b in address.GetAddressBytes()) + { + if (b != 0) return false; + } + return true; + } +} \ No newline at end of file diff --git a/Tests/Microsoft.Teams.Plugins.External.McpClient.Tests/UrlValidationTests.cs b/Tests/Microsoft.Teams.Plugins.External.McpClient.Tests/UrlValidationTests.cs new file mode 100644 index 00000000..bf6d0eda --- /dev/null +++ b/Tests/Microsoft.Teams.Plugins.External.McpClient.Tests/UrlValidationTests.cs @@ -0,0 +1,197 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +using System.Net; + +namespace Microsoft.Teams.Plugins.External.McpClient.Tests; + +public class UrlValidationTests : IDisposable +{ + private readonly Func> _originalResolver; + + public UrlValidationTests() + { + _originalResolver = UrlValidation.HostResolver; + } + + public void Dispose() + { + UrlValidation.HostResolver = _originalResolver; + GC.SuppressFinalize(this); + } + + private static void StubResolver(params IPAddress[] addresses) + { + UrlValidation.HostResolver = (_, _) => Task.FromResult(addresses); + } + + [Theory] + [InlineData("127.0.0.1", true)] + [InlineData("10.0.0.1", true)] + [InlineData("10.255.255.255", true)] + [InlineData("172.16.0.1", true)] + [InlineData("172.31.255.255", true)] + [InlineData("192.168.1.1", true)] + [InlineData("169.254.169.254", true)] + [InlineData("8.8.8.8", false)] + [InlineData("1.1.1.1", false)] + [InlineData("172.15.0.1", false)] + [InlineData("172.32.0.1", false)] + [InlineData("::1", true)] + [InlineData("fc00::1", true)] + [InlineData("fd00::1", true)] + [InlineData("fe80::1", true)] + [InlineData("fec0::1", true)] + [InlineData("::", true)] + [InlineData("2001:4860:4860::8888", false)] + public void IsPrivateAddress_ReturnsExpectedClassification(string address, bool expected) + { + Assert.Equal(expected, UrlValidation.IsPrivateAddress(IPAddress.Parse(address))); + } + + [Fact] + public async Task RejectsNonHttpSchemes() + { + await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("file:///etc/passwd")) + ); + await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("ftp://example.com")) + ); + } + + [Fact] + public async Task AcceptsPublicUrlWithPublicDns() + { + StubResolver(IPAddress.Parse("8.8.8.8")); + var result = await UrlValidation.ValidateMcpServerUrlAsync(new Uri("https://example.com/mcp")); + Assert.Equal("https://example.com/mcp", result.ToString()); + } + + [Fact] + public async Task RejectsUrlResolvingToPrivateIp() + { + StubResolver(IPAddress.Parse("10.0.0.5")); + var ex = await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("https://internal.example.com/mcp")) + ); + Assert.Contains("private or loopback", ex.Message); + } + + [Fact] + public async Task RejectsWhenAnyResolvedAddressIsPrivate() + { + StubResolver(IPAddress.Parse("8.8.8.8"), IPAddress.Parse("192.168.1.1")); + await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("https://mixed.example.com/mcp")) + ); + } + + [Fact] + public async Task RejectsIpLiteralPrivate() + { + // IP literals short-circuit DNS, so the resolver should never be called. + bool resolverCalled = false; + UrlValidation.HostResolver = (_, _) => + { + resolverCalled = true; + return Task.FromResult(Array.Empty()); + }; + + await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("http://127.0.0.1:3000")) + ); + Assert.False(resolverCalled); + } + + [Fact] + public async Task AcceptsPrivateIpWhenAllowPrivateNetwork() + { + var result = await UrlValidation.ValidateMcpServerUrlAsync( + new Uri("http://127.0.0.1:3000"), + allowPrivateNetwork: true + ); + Assert.Equal("http://127.0.0.1:3000/", result.ToString()); + } + + [Fact] + public async Task AcceptsPrivateHostnameWhenAllowPrivateNetworkSkipsDns() + { + bool resolverCalled = false; + UrlValidation.HostResolver = (_, _) => + { + resolverCalled = true; + return Task.FromResult(new[] { IPAddress.Parse("192.168.1.1") }); + }; + + var result = await UrlValidation.ValidateMcpServerUrlAsync( + new Uri("https://internal.example.com/mcp"), + allowPrivateNetwork: true + ); + Assert.NotNull(result); + Assert.False(resolverCalled); + } + + [Fact] + public async Task ValidateUrlFullyReplacesDefaultChecks() + { + var seen = new List(); + var result = await UrlValidation.ValidateMcpServerUrlAsync( + new Uri("file:///etc/passwd"), + validateUrl: url => + { + seen.Add(url); + return Task.FromResult(true); + } + ); + Assert.Single(seen); + Assert.Equal("file", seen[0].Scheme); + Assert.NotNull(result); + } + + [Fact] + public async Task ValidateUrlRejectsWhenReturningFalse() + { + var ex = await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync( + new Uri("https://example.com/mcp"), + validateUrl: _ => Task.FromResult(false) + ) + ); + Assert.Contains("rejected by ValidateUrl", ex.Message); + } + + [Fact] + public async Task RejectsWhenDnsLookupFails() + { + UrlValidation.HostResolver = (_, _) => + throw new System.Net.Sockets.SocketException(11001); // host not found + + var ex = await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("https://nonexistent.invalid/mcp")) + ); + Assert.Contains("Could not resolve host", ex.Message); + } + + [Fact] + public async Task RejectsWhenDnsReturnsEmptyList() + { + StubResolver(); // empty array + + var ex = await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync(new Uri("https://example.com/mcp")) + ); + Assert.Contains("did not resolve", ex.Message); + } + + [Fact] + public async Task PropagatesExceptionsFromValidateUrl() + { + await Assert.ThrowsAsync( + () => UrlValidation.ValidateMcpServerUrlAsync( + new Uri("https://example.com/mcp"), + validateUrl: _ => throw new InvalidOperationException("custom failure") + ) + ); + } +} \ No newline at end of file