chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 13:39:25 +08:00
commit db620d33df
5151 changed files with 925932 additions and 0 deletions
@@ -0,0 +1,15 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Minimal empty <see cref="IServiceProvider"/> for in-memory fixtures that don't use DI.
/// </summary>
internal sealed class EmptyServiceProvider : IServiceProvider
{
public static EmptyServiceProvider Instance { get; } = new();
public object? GetService(Type serviceType) => null;
}
@@ -0,0 +1,127 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.IO;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// In-process MCP server fixture that pairs a <see cref="McpServer"/> and a <see cref="McpClient"/>
/// over duplex <see cref="Pipe"/>-backed streams so unit tests can exercise the
/// real task-augmentation protocol without spawning a child process or opening a socket.
/// </summary>
internal sealed class InMemoryMcpServerFixture : IAsyncDisposable
{
private readonly McpServer _server;
private readonly Task _serverLoop;
private readonly CancellationTokenSource _cts;
public McpClient Client { get; }
private InMemoryMcpServerFixture(McpServer server, McpClient client, Task serverLoop, CancellationTokenSource cts)
{
this._server = server;
this.Client = client;
this._serverLoop = serverLoop;
this._cts = cts;
}
public static async Task<InMemoryMcpServerFixture> CreateAsync(
McpServerPrimitiveCollection<McpServerTool> tools,
CancellationToken cancellationToken = default)
{
Pipe clientToServer = new();
Pipe serverToClient = new();
// Stream conventions:
// StreamClientTransport(serverInput, serverOutput, ...): serverInput is what the client
// WRITES to (server reads it); serverOutput is what the client READS from (server writes it).
// StreamServerTransport(input, output, ...): input is what the server READS from; output
// is what the server WRITES to.
Stream clientWriteStream = clientToServer.Writer.AsStream();
Stream clientReadStream = serverToClient.Reader.AsStream();
Stream serverReadStream = clientToServer.Reader.AsStream();
Stream serverWriteStream = serverToClient.Writer.AsStream();
StreamServerTransport serverTransport = new(
serverReadStream,
serverWriteStream,
"test-server",
NullLoggerFactory.Instance);
McpServerOptions serverOptions = new()
{
ServerInfo = new Implementation { Name = "test-server", Version = "1.0.0" },
TaskStore = new InMemoryMcpTaskStore(),
ToolCollection = tools,
};
McpServer server = McpServer.Create(
serverTransport,
serverOptions,
NullLoggerFactory.Instance,
EmptyServiceProvider.Instance);
CancellationTokenSource cts = new();
Task serverLoop = Task.Run(() => server.RunAsync(cts.Token), cts.Token);
StreamClientTransport clientTransport = new(
clientWriteStream,
clientReadStream,
NullLoggerFactory.Instance);
McpClient client = await McpClient.CreateAsync(
clientTransport,
clientOptions: null,
NullLoggerFactory.Instance,
cancellationToken).ConfigureAwait(false);
return new InMemoryMcpServerFixture(server, client, serverLoop, cts);
}
public async ValueTask DisposeAsync()
{
try
{
await this.Client.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best effort.
}
this._cts.Cancel();
try
{
await this._serverLoop.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected.
}
catch
{
// Best effort.
}
try
{
await this._server.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best effort.
}
this._cts.Dispose();
}
}
@@ -0,0 +1,55 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class ListAgentToolsWithTaskSupportTests
{
[Fact]
public async Task ListAgentToolsWithTaskSupport_WrapsTaskCapableTools_LeavesOthersAsIsAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("opt", ToolTaskSupport.Optional, () => "opt-result"),
TestTools.Create("req", ToolTaskSupport.Required, () => "req-result"),
TestTools.Create("forb", ToolTaskSupport.Forbidden, () => "forb-result"),
TestTools.Create("none", taskSupport: null, () => "none-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
// Act
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
// Assert
result.Should().HaveCount(4);
AIFunction opt = result.Single(f => f.Name == "opt");
AIFunction req = result.Single(f => f.Name == "req");
AIFunction forb = result.Single(f => f.Name == "forb");
AIFunction none = result.Single(f => f.Name == "none");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>("Required tools must be wrapped");
opt.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Optional tools must not be wrapped; inline invocation is preserved by default");
forb.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Forbidden tools must not be wrapped");
none.Should().NotBeOfType<TaskAwareMcpClientAIFunction>("Tools without execution metadata must not be wrapped");
}
[Fact]
public async Task ListAgentToolsWithTaskSupport_ThrowsOnNullClientAsync()
{
// Arrange
ModelContextProtocol.Client.McpClient client = null!;
// Act
Func<Task> act = async () => await client.ListAgentToolsWithTaskSupportAsync();
// Assert
await act.Should().ThrowAsync<ArgumentNullException>();
}
}
@@ -0,0 +1,19 @@
// Copyright (c) Microsoft. All rights reserved.
using FluentAssertions;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class McpTaskOptionsTests
{
[Fact]
public void Defaults_AreSane()
{
// Act
McpTaskOptions options = new();
// Assert
options.DefaultTimeToLive.Should().BeNull();
options.CancelRemoteTaskOnLocalCancellation.Should().BeTrue();
}
}
@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
<NoWarn>$(NoWarn);MCPEXP001</NoWarn>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
<PackageReference Include="Microsoft.Extensions.Logging" />
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
<PackageReference Include="ModelContextProtocol" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,928 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Formats.Tar;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests;
/// <summary>
/// Unit tests for archive-distributed skill handling in <see cref="AgentMcpSkillsSource"/>.
/// </summary>
public sealed class AgentMcpSkillsSourceArchiveTests : IDisposable
{
private const string ArchivedSkillMd = """
---
name: archived-skill
description: A skill delivered as an archive.
---
# Archived Skill
Body from the archive.
""";
private const string StaleSkillMd = """
---
name: stale-skill
description: Left over from a previous session.
---
Old content.
""";
private const string SkillAMd = """
---
name: skill-a
description: Skill A.
---
Content A.
""";
private const string SkillBMd = """
---
name: skill-b
description: Skill B.
---
Content B.
""";
private readonly string _extractionRoot =
Path.Combine(Path.GetTempPath(), "af-mcp-archive-tests", Guid.NewGuid().ToString("N"));
private const int ManyFileArchiveFileCount = 60;
[Fact]
public async Task GetSkillsAsync_ZipArchive_DiscoversSkillAsync()
{
// Arrange
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ZipArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
var skill = Assert.Single(skills);
Assert.Equal("archived-skill", skill.Frontmatter.Name);
Assert.Equal("A skill delivered as an archive.", skill.Frontmatter.Description);
Assert.Contains("Body from the archive.", await skill.GetContentAsync());
}
[Fact]
public async Task GetSkillsAsync_TarGzArchive_DiscoversSkillAsync()
{
// Arrange
await using var server = new InMemoryMcpServer(builder => builder.WithResources<TarGzArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
var skill = Assert.Single(skills);
Assert.Equal("archived-skill", skill.Frontmatter.Name);
Assert.Contains("Body from the archive.", await skill.GetContentAsync());
}
[Fact]
public async Task GetSkillsAsync_ArchiveWithScript_SurfacesScriptAsReadableResourceAsync()
{
// Arrange - archive bundles a script file alongside SKILL.md. Over MCP, scripts must never be
// executable; they are surfaced as readable resources only when explicitly included via options.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ZipWithScriptServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions
{
ArchiveSkillsDirectory = this._extractionRoot,
ArchiveResourceExtensions = [".md", ".json", ".yaml", ".yml", ".csv", ".xml", ".txt", ".py"],
};
var source = new AgentMcpSkillsSource(client, options);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var resource = await skill.GetResourceAsync("scripts/run.py");
// Assert - the .py file is readable as a resource (not an executable script).
Assert.NotNull(resource);
var content = await resource!.ReadAsync();
Assert.Equal("print('hello')", content);
}
[Fact]
public async Task GetSkillsAsync_TwoSourcesWithSeparateDirectories_DoNotCollideAsync()
{
// Arrange - two servers publish an archive skill with the SAME name but different content.
// Pointing each source at its own directory keeps their extracted content separate.
await using var serverA = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerA>());
await using var clientA = await serverA.CreateClientAsync();
await using var serverB = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerB>());
await using var clientB = await serverB.CreateClientAsync();
var sourceA = new AgentMcpSkillsSource(clientA, new() { ArchiveSkillsDirectory = Path.Combine(this._extractionRoot, "a") });
var sourceB = new AgentMcpSkillsSource(clientB, new() { ArchiveSkillsDirectory = Path.Combine(this._extractionRoot, "b") });
// Act
var skillA = Assert.Single(await sourceA.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var skillB = Assert.Single(await sourceB.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
// Assert - each source kept its own content despite the shared skill name.
Assert.Equal("shared-skill", skillA.Frontmatter.Name);
Assert.Equal("shared-skill", skillB.Frontmatter.Name);
Assert.Contains("Content from server A.", await skillA.GetContentAsync());
Assert.Contains("Content from server B.", await skillB.GetContentAsync());
}
[Fact]
public async Task GetSkillsAsync_FirstRun_PrunesLeftoverSkillDirectoryAsync()
{
// Arrange - a leftover skill directory from a previous session sits in the provided directory.
string staleDir = Path.Combine(this._extractionRoot, "stale-skill");
Directory.CreateDirectory(staleDir);
await File.WriteAllTextAsync(Path.Combine(staleDir, "SKILL.md"), StaleSkillMd);
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ZipArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - the leftover directory is pruned and only the advertised skill remains.
Assert.False(Directory.Exists(staleDir));
var skill = Assert.Single(skills);
Assert.Equal("archived-skill", skill.Frontmatter.Name);
}
[Fact]
public async Task GetSkillsAsync_SkillNoLongerAdvertised_IsPrunedAsync()
{
// Arrange - an earlier run extracts skill-a and skill-b into the shared directory.
await using var fullServer = new InMemoryMcpServer(builder => builder.WithResources<TwoSkillServer>());
await using var fullClient = await fullServer.CreateClientAsync();
var firstSource = new AgentMcpSkillsSource(fullClient, new() { ArchiveSkillsDirectory = this._extractionRoot });
var firstSkills = await firstSource.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
Assert.Equal(2, firstSkills.Count);
Assert.True(Directory.Exists(Path.Combine(this._extractionRoot, "skill-b")));
// Act - a later run sees only skill-a.
await using var partialServer = new InMemoryMcpServer(builder => builder.WithResources<OneSkillServer>());
await using var partialClient = await partialServer.CreateClientAsync();
var secondSource = new AgentMcpSkillsSource(partialClient, new() { ArchiveSkillsDirectory = this._extractionRoot });
var secondSkills = await secondSource.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - skill-b's directory was pruned; only skill-a remains.
var skill = Assert.Single(secondSkills);
Assert.Equal("skill-a", skill.Frontmatter.Name);
Assert.False(Directory.Exists(Path.Combine(this._extractionRoot, "skill-b")));
Assert.True(Directory.Exists(Path.Combine(this._extractionRoot, "skill-a")));
}
[Fact]
public async Task GetSkillsAsync_ServerListsNoArchives_PrunesLeftoversAsync()
{
// Arrange - a leftover skill directory exists but the server advertises no archive skills.
string staleDir = Path.Combine(this._extractionRoot, "stale-skill");
Directory.CreateDirectory(staleDir);
await File.WriteAllTextAsync(Path.Combine(staleDir, "SKILL.md"), StaleSkillMd);
await using var server = new InMemoryMcpServer(builder => builder.WithResources<NoArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - the leftover directory is pruned and no skills are returned.
Assert.Empty(skills);
Assert.False(Directory.Exists(staleDir));
}
[Fact]
public async Task GetSkillsAsync_SecondDiscovery_ReExtractsContentAsync()
{
// Arrange - a first run extracts server A's content into the shared directory.
await using (var serverA = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerA>()))
await using (var clientA = await serverA.CreateClientAsync())
{
var firstSource = new AgentMcpSkillsSource(clientA, new() { ArchiveSkillsDirectory = this._extractionRoot });
Assert.Single(await firstSource.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
}
// Act - a second run over the same directory re-extracts server B's content.
await using var serverB = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerB>());
await using var clientB = await serverB.CreateClientAsync();
var source = new AgentMcpSkillsSource(clientB, new() { ArchiveSkillsDirectory = this._extractionRoot });
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
// Assert - the content was replaced with server B's.
Assert.Contains("Content from server B.", await skill.GetContentAsync());
}
[Fact]
public async Task GetSkillsAsync_PreExtractionCleanupFails_DoesNotDiscoverStaleSkillAsync()
{
if (!OperatingSystem.IsWindows())
{
return;
}
// Arrange - a stale skill directory contains a locked file, causing recursive deletion to fail.
string skillDirectory = Path.Combine(this._extractionRoot, "shared-skill");
Directory.CreateDirectory(skillDirectory);
await File.WriteAllTextAsync(Path.Combine(skillDirectory, "SKILL.md"), """
---
name: shared-skill
description: Shared.
---
Stale content.
""");
string lockedResourcePath = Path.Combine(skillDirectory, "locked.txt");
await using var lockedResource = new FileStream(lockedResourcePath, FileMode.Create, FileAccess.ReadWrite, FileShare.None);
await using var server = new InMemoryMcpServer(builder => builder.WithResources<SharedNameServerB>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot });
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - failed cleanup prevents the stale directory from being proxied to AgentFileSkillsSource.
Assert.Empty(skills);
Assert.True(File.Exists(lockedResourcePath));
}
[Fact]
public void Extract_ZipSlipEntry_DoesNotEscapeTargetDirectory()
{
// Arrange - a malicious zip whose entry traverses out of the target directory.
byte[] zip = BuildZip(("../escaped.txt", "pwned"), ("SKILL.md", ArchivedSkillMd));
string target = Path.Combine(this._extractionRoot, "skill");
// Act
AgentMcpSkillArchiveExtractor.Extract(zip, ArchiveFormat.Zip, target);
// Assert - the traversal entry was skipped; only the safe entry was written.
Assert.False(File.Exists(Path.Combine(this._extractionRoot, "escaped.txt")));
Assert.True(File.Exists(Path.Combine(target, "SKILL.md")));
}
[Fact]
public void Extract_CaseVariantZipSlipEntry_DoesNotEscapeTargetDirectory()
{
if (OperatingSystem.IsWindows())
{
return;
}
// Arrange - on case-sensitive file systems, "skill" is a sibling of "Skill", not the target.
byte[] zip = BuildZip(("../skill/escaped.txt", "pwned"), ("SKILL.md", ArchivedSkillMd));
string target = Path.Combine(this._extractionRoot, "Skill");
// Act
AgentMcpSkillArchiveExtractor.Extract(zip, ArchiveFormat.Zip, target);
// Assert - the case-variant traversal entry was skipped; only the safe entry was written.
Assert.False(File.Exists(Path.Combine(this._extractionRoot, "skill", "escaped.txt")));
Assert.True(File.Exists(Path.Combine(target, "SKILL.md")));
}
[Fact]
public void Extract_ArchiveExceedsDefaultFileCount_Throws()
{
// Arrange - more files than the default cap.
var entries = Enumerable.Range(0, AgentMcpSkillArchiveExtractor.DefaultMaxFileCount + 1)
.Select(i => ($"file{i}.txt", "x"))
.ToArray();
byte[] zip = BuildZip(entries);
string target = Path.Combine(this._extractionRoot, "skill");
// Act / Assert
Assert.Throws<InvalidDataException>(
() => AgentMcpSkillArchiveExtractor.Extract(zip, ArchiveFormat.Zip, target));
}
[Fact]
public void Extract_ArchiveExceedsDefaultUncompressedSize_Throws()
{
// Arrange - a single file larger than the default uncompressed budget (1 MB).
string oversized = new('x', (int)AgentMcpSkillArchiveExtractor.DefaultMaxUncompressedSizeBytes + 1);
byte[] zip = BuildZip(("SKILL.md", oversized));
string target = Path.Combine(this._extractionRoot, "skill");
// Act / Assert
Assert.Throws<InvalidDataException>(
() => AgentMcpSkillArchiveExtractor.Extract(zip, ArchiveFormat.Zip, target));
}
[Fact]
public void Extract_TarGzExceedsUncompressedSize_Throws()
{
// Arrange - a gzip-compressed tar whose expansion exceeds the default budget. The ZIP pre-gate
// does not apply here, so this exercises the authoritative streaming cap (CopyWithLimit).
string oversized = new('x', (int)AgentMcpSkillArchiveExtractor.DefaultMaxUncompressedSizeBytes + 1);
byte[] tarGz = BuildTarGz(("SKILL.md", oversized));
string target = Path.Combine(this._extractionRoot, "skill");
// Act / Assert
Assert.Throws<InvalidDataException>(
() => AgentMcpSkillArchiveExtractor.Extract(tarGz, ArchiveFormat.TarGz, target));
}
[Fact]
public void Extract_TarWithLinkEntries_SkipsLinksAndExtractsRegularFiles()
{
// Arrange - a tar.gz containing symbolic-link and hard-link entries whose targets escape the
// target directory, alongside a regular file. Link entries must be skipped so an archive cannot
// create links that point outside the target directory.
byte[] tarGz = BuildTarGzFromEntries(
new PaxTarEntry(TarEntryType.SymbolicLink, "evil-symlink") { LinkName = "../../escaped.txt" },
new PaxTarEntry(TarEntryType.HardLink, "evil-hardlink") { LinkName = "../../escaped.txt" },
new PaxTarEntry(TarEntryType.RegularFile, "SKILL.md")
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(ArchivedSkillMd)),
});
string target = Path.Combine(this._extractionRoot, "skill");
// Act
AgentMcpSkillArchiveExtractor.Extract(tarGz, ArchiveFormat.TarGz, target);
// Assert - only the regular file is materialized; neither link entry is written.
Assert.True(File.Exists(Path.Combine(target, "SKILL.md")));
Assert.False(File.Exists(Path.Combine(target, "evil-symlink")));
Assert.False(File.Exists(Path.Combine(target, "evil-hardlink")));
Assert.Single(Directory.GetFileSystemEntries(target));
}
[Fact]
public void Extract_WithinDefaultLimits_Succeeds()
{
// Arrange - a typical small archive that is comfortably within the default limits.
byte[] zip = BuildZip(("SKILL.md", ArchivedSkillMd), ("reference.md", "Some reference content."));
string target = Path.Combine(this._extractionRoot, "skill");
// Act
AgentMcpSkillArchiveExtractor.Extract(zip, ArchiveFormat.Zip, target);
// Assert
Assert.True(File.Exists(Path.Combine(target, "SKILL.md")));
Assert.True(File.Exists(Path.Combine(target, "reference.md")));
}
[Fact]
public void Extract_RaisedLimits_AllowsLargerArchive()
{
// Arrange - an archive that exceeds the default file count but fits within raised limits.
var entries = Enumerable.Range(0, AgentMcpSkillArchiveExtractor.DefaultMaxFileCount + 1)
.Select(i => ($"file{i}.txt", "x"))
.ToArray();
byte[] zip = BuildZip(entries);
string target = Path.Combine(this._extractionRoot, "skill");
// Act
AgentMcpSkillArchiveExtractor.Extract(
zip,
ArchiveFormat.Zip,
target,
maxFileCount: entries.Length + 1);
// Assert - every entry was extracted.
Assert.Equal(entries.Length, Directory.GetFiles(target).Length);
}
[Fact]
public async Task GetSkillsAsync_ArchiveExceedsDefaultFileCount_SkillSkippedAsync()
{
// Arrange - the archive has more files than the default cap, so extraction fails and the skill is skipped.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ManyFileArchiveServer>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client, new() { ArchiveSkillsDirectory = this._extractionRoot });
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_ArchiveExceedsConfiguredArchiveSize_SkillSkippedAsync()
{
// Arrange - the valid archive is larger than the configured downloaded-archive byte cap.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ZipArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions
{
ArchiveSkillsDirectory = this._extractionRoot,
ArchiveMaxSizeBytes = 1,
};
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_RaisedFileCountOption_LoadsLargerArchiveAsync()
{
// Arrange - raising ArchiveMaxFileCount lets the larger archive through.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<ManyFileArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions
{
ArchiveSkillsDirectory = this._extractionRoot,
ArchiveMaxFileCount = ManyFileArchiveFileCount + 1,
};
var source = new AgentMcpSkillsSource(client, options);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
// Assert
Assert.Equal("archived-skill", skill.Frontmatter.Name);
}
[Fact]
public async Task GetSkillsAsync_EntryMissingName_SkillSkippedAsync()
{
// Arrange - an archive index entry with a null/empty name is not actionable.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<MissingNameServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - the invalid entry is skipped; no skills are surfaced.
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_EntryWithInvalidNameChars_SkillSkippedAsync()
{
// Arrange - a name containing path separator characters is not a valid directory name.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<InvalidNameCharsServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_EntryMissingUrl_SkillSkippedAsync()
{
// Arrange - an archive entry without a url cannot be downloaded.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<MissingUrlServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_ArchiveWithUnsupportedFormat_SkillSkippedAsync()
{
// Arrange - the archive payload does not match any known format (no magic bytes, no matching
// media type, no recognized URL extension).
await using var server = new InMemoryMcpServer(builder => builder.WithResources<UnsupportedFormatServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_ArchiveReturnsTextNotBlob_SkillSkippedAsync()
{
// Arrange - the archive resource returns text content instead of a binary blob.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<TextOnlyArchiveServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_ConcurrentCalls_ReconcilesArchiveDirectorySafelyAsync()
{
// Arrange - a fixed extraction directory means every concurrent call reconciles the same
// on-disk location. The per-instance lock must serialize that reconcile/extract/read work so
// that no call observes a half-extracted or mid-prune directory.
await using var server = new InMemoryMcpServer(builder => builder.WithResources<TwoSkillServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act - hammer the source from many threads at once.
var tasks = Enumerable.Range(0, 20)
.Select(_ => Task.Run(() => source.GetSkillsAsync(context)))
.ToArray();
var results = await Task.WhenAll(tasks);
// Assert - every call returns both skills with intact content.
foreach (var skills in results)
{
Assert.Equal(2, skills.Count);
var byName = skills.ToDictionary(s => s.Frontmatter.Name);
Assert.Contains("Content A.", await byName["skill-a"].GetContentAsync());
Assert.Contains("Content B.", await byName["skill-b"].GetContentAsync());
}
}
[Fact]
public async Task GetSkillsAsync_ArchiveUpdatedBetweenCalls_ReturnsUpdatedContentAsync()
{
// Arrange - a mutable server whose archive body the test can change to simulate the skill
// being republished. Because the loader re-extracts under its reconcile lock on every call,
// a query issued after the update (and any calls queued behind the lock) must observe the
// new content, never a stale pre-update snapshot.
MutableSkillServer.Body = "Version one.";
await using var server = new InMemoryMcpServer(builder => builder.WithResources<MutableSkillServer>());
await using var client = await server.CreateClientAsync();
var options = new AgentMcpSkillsSourceOptions { ArchiveSkillsDirectory = this._extractionRoot };
var source = new AgentMcpSkillsSource(client, options);
var context = TestAgentSkillsSourceContextFactory.Create();
// Act - the initial load observes the original content.
var before = await source.GetSkillsAsync(context);
// The skill is republished with new content.
MutableSkillServer.Body = "Version two.";
// Fire many post-update calls at once; they serialize behind the reconcile lock.
var tasks = Enumerable.Range(0, 10)
.Select(_ => Task.Run(() => source.GetSkillsAsync(context)))
.ToArray();
var afterResults = await Task.WhenAll(tasks);
// Assert - the first load saw the old content.
Assert.Contains("Version one.", await Assert.Single(before).GetContentAsync());
// Every post-update call sees the new content and not the stale pre-update body.
foreach (var skills in afterResults)
{
var content = await Assert.Single(skills).GetContentAsync();
Assert.Contains("Version two.", content);
Assert.DoesNotContain("Version one.", content);
}
}
public void Dispose()
{
try
{
if (Directory.Exists(this._extractionRoot))
{
Directory.Delete(this._extractionRoot, recursive: true);
}
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
// Best-effort cleanup.
}
}
private static byte[] BuildZip(params (string Path, string Content)[] entries)
{
using var ms = new MemoryStream();
using (var archive = new ZipArchive(ms, ZipArchiveMode.Create, leaveOpen: true))
{
foreach (var (path, content) in entries)
{
ZipArchiveEntry entry = archive.CreateEntry(path);
using Stream stream = entry.Open();
byte[] bytes = Encoding.UTF8.GetBytes(content);
stream.Write(bytes, 0, bytes.Length);
}
}
return ms.ToArray();
}
private static byte[] BuildTarGz(params (string Path, string Content)[] entries)
{
using var ms = new MemoryStream();
using (var gzip = new GZipStream(ms, CompressionMode.Compress, leaveOpen: true))
using (var writer = new TarWriter(gzip, leaveOpen: true))
{
foreach (var (path, content) in entries)
{
var entry = new PaxTarEntry(TarEntryType.RegularFile, path)
{
DataStream = new MemoryStream(Encoding.UTF8.GetBytes(content)),
};
writer.WriteEntry(entry);
}
}
return ms.ToArray();
}
private static byte[] BuildTarGzFromEntries(params TarEntry[] entries)
{
using var ms = new MemoryStream();
using (var gzip = new GZipStream(ms, CompressionMode.Compress, leaveOpen: true))
using (var writer = new TarWriter(gzip, leaveOpen: true))
{
foreach (var entry in entries)
{
writer.WriteEntry(entry);
}
}
return ms.ToArray();
}
private static string ArchiveIndex(string skillName, string url) => $$"""
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "{{skillName}}",
"type": "archive",
"description": "A skill delivered as an archive.",
"url": "{{url}}"
}
]
}
""";
#region Resource classes (registered with the MCP server via WithResources<T>)
#pragma warning disable CA1812
[McpServerResourceType]
private sealed class ZipArchiveServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/archived-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", ArchivedSkillMd)),
"skill://archives/archived-skill.zip",
"application/zip");
}
[McpServerResourceType]
private sealed class TarGzArchiveServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("archived-skill", "skill://archives/archived-skill.tar.gz");
[McpServerResource(UriTemplate = "skill://archives/archived-skill.tar.gz", Name = "archive", MimeType = "application/gzip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildTarGz(("SKILL.md", ArchivedSkillMd)),
"skill://archives/archived-skill.tar.gz",
"application/gzip");
}
[McpServerResourceType]
private sealed class ZipWithScriptServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/archived-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", ArchivedSkillMd), ("scripts/run.py", "print('hello')")),
"skill://archives/archived-skill.zip",
"application/zip");
}
[McpServerResourceType]
private sealed class ManyFileArchiveServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/archived-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive()
{
var entries = new List<(string Path, string Content)> { ("SKILL.md", ArchivedSkillMd) };
for (int i = 0; i < ManyFileArchiveFileCount - 1; i++)
{
entries.Add(($"reference/file{i}.txt", "x"));
}
return BlobResourceContents.FromBytes(
BuildZip(entries.ToArray()),
"skill://archives/archived-skill.zip",
"application/zip");
}
}
[McpServerResourceType]
private sealed class SharedNameServerA
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("shared-skill", "skill://archives/shared-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/shared-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", """
---
name: shared-skill
description: Shared.
---
Content from server A.
""")),
"skill://archives/shared-skill.zip",
"application/zip");
}
[McpServerResourceType]
private sealed class SharedNameServerB
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("shared-skill", "skill://archives/shared-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/shared-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", """
---
name: shared-skill
description: Shared.
---
Content from server B.
""")),
"skill://archives/shared-skill.zip",
"application/zip");
}
[McpServerResourceType]
private sealed class TwoSkillServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{ "name": "skill-a", "type": "archive", "description": "Skill A.", "url": "skill://archives/skill-a.zip" },
{ "name": "skill-b", "type": "archive", "description": "Skill B.", "url": "skill://archives/skill-b.zip" }
]
}
""";
[McpServerResource(UriTemplate = "skill://archives/skill-a.zip", Name = "skill-a", MimeType = "application/zip")]
public static BlobResourceContents SkillA() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", SkillAMd)), "skill://archives/skill-a.zip", "application/zip");
[McpServerResource(UriTemplate = "skill://archives/skill-b.zip", Name = "skill-b", MimeType = "application/zip")]
public static BlobResourceContents SkillB() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", SkillBMd)), "skill://archives/skill-b.zip", "application/zip");
}
[McpServerResourceType]
private sealed class MutableSkillServer
{
// Toggled by the test to simulate the server republishing the skill with new content.
public static volatile string Body = "Version one.";
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("archived-skill", "skill://archives/archived-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/archived-skill.zip", Name = "archive", MimeType = "application/zip")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", $$"""
---
name: archived-skill
description: A skill delivered as an archive.
---
{{Body}}
""")),
"skill://archives/archived-skill.zip",
"application/zip");
}
[McpServerResourceType]
private sealed class OneSkillServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("skill-a", "skill://archives/skill-a.zip");
[McpServerResource(UriTemplate = "skill://archives/skill-a.zip", Name = "skill-a", MimeType = "application/zip")]
public static BlobResourceContents SkillA() => BlobResourceContents.FromBytes(
BuildZip(("SKILL.md", SkillAMd)), "skill://archives/skill-a.zip", "application/zip");
}
[McpServerResourceType]
private sealed class NoArchiveServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": []
}
""";
}
[McpServerResourceType]
private sealed class MissingNameServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{ "name": "", "type": "archive", "description": "No name.", "url": "skill://archives/x.zip" }
]
}
""";
}
[McpServerResourceType]
private sealed class InvalidNameCharsServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{ "name": "../escape", "type": "archive", "description": "Bad name.", "url": "skill://archives/x.zip" }
]
}
""";
}
[McpServerResourceType]
private sealed class MissingUrlServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{ "name": "good-name", "type": "archive", "description": "No url.", "url": "" }
]
}
""";
}
[McpServerResourceType]
private sealed class UnsupportedFormatServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("bad-format", "skill://archives/bad-format.bin");
[McpServerResource(UriTemplate = "skill://archives/bad-format.bin", Name = "archive", MimeType = "application/octet-stream")]
public static BlobResourceContents Archive() => BlobResourceContents.FromBytes(
Encoding.UTF8.GetBytes("not an archive"),
"skill://archives/bad-format.bin",
"application/octet-stream");
}
[McpServerResourceType]
private sealed class TextOnlyArchiveServer
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => ArchiveIndex("text-skill", "skill://archives/text-skill.zip");
[McpServerResource(UriTemplate = "skill://archives/text-skill.zip", Name = "archive", MimeType = "application/zip")]
public static string Archive() => "this is not binary";
}
#pragma warning restore CA1812
#endregion
}
@@ -0,0 +1,393 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests;
/// <summary>
/// Unit tests for <see cref="AgentMcpSkillsSource"/>.
/// </summary>
public sealed class AgentMcpSkillsSourceTests
{
private const string SampleSkillMd = """
---
name: unit-converter
description: Convert between common units.
---
# Unit Converter
Body content here.
""";
private const string SampleSkillIndex = """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://unit-converter/SKILL.md"
}
]
}
""";
[Fact]
public async Task GetSkillsAsync_IndexBasedDiscovery_ReturnsSkillAsync()
{
// Arrange - server exposes both skill://index.json and the skill itself.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexAndSkill>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - frontmatter comes from index; Content is the actual SKILL.md body from the server.
var skill = Assert.Single(skills);
Assert.Equal("unit-converter", skill.Frontmatter.Name);
Assert.Equal("Convert between common units.", skill.Frontmatter.Description);
string content = await skill.GetContentAsync();
Assert.Contains("name: unit-converter", content);
Assert.Contains("description: Convert between common units.", content);
Assert.Contains("Body content here.", content);
}
[Fact]
public async Task GetSkillsAsync_NoIndex_ReturnsEmptyAsync()
{
// Arrange - server only exposes SKILL.md, no skill://index.json.
// Per SEP-2640, discovery requires the index document; without it, no skills are surfaced.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<SkillOnly>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetResourceAsync_SiblingText_ReturnsContentAsync()
{
// Arrange - server exposes index, SKILL.md, and a sibling reference file.
// The skill reads the sibling on demand via GetResourceAsync.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexAndSkillWithSibling>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var resource = await skill.GetResourceAsync("references/checklist.md");
// Assert
Assert.NotNull(resource);
var content = await resource!.ReadAsync();
Assert.Equal("- check thing 1\n- check thing 2", content);
}
[Fact]
public async Task GetResourceAsync_SiblingBinary_ReturnsDataContentAsync()
{
// Arrange - server exposes index, SKILL.md, and a binary sibling.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexAndSkillWithBinarySibling>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var resource = await skill.GetResourceAsync("assets/icon.bin");
// Assert
Assert.NotNull(resource);
var content = await resource!.ReadAsync();
var dataContent = Assert.IsType<DataContent>(content);
Assert.Equal("application/octet-stream", dataContent.MediaType);
Assert.Equal([0x01, 0x02, 0x03, 0x04], dataContent.Data.ToArray());
}
[Fact]
public async Task GetResourceAsync_UnknownName_ReturnsNullAsync()
{
// Arrange - index advertises a skill, but no sibling resource exists.
// GetResourceAsync eagerly fetches from the MCP server; a non-existent
// resource causes the server to return an error, so null is returned.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexAndSkill>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var resource = await skill.GetResourceAsync("references/does-not-exist.md");
// Assert - resource does not exist on the server, so null is returned
Assert.Null(resource);
}
[Theory]
[InlineData("../escape.md")]
[InlineData("references/../../escape.md")]
[InlineData("..")]
public async Task GetResourceAsync_PathTraversalName_ReturnsNullAsync(string name)
{
// Arrange - '..' segments result in URIs that don't match any server resource.
// The MCP server returns an error for unknown URIs, so GetResourceAsync returns null.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexAndSkill>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skill = Assert.Single(await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create()));
var resource = await skill.GetResourceAsync(name);
// Assert - resource does not exist on the server, so null is returned
Assert.Null(resource);
}
[Fact]
public async Task GetSkillsAsync_DoesNotReadSkillMdAsync()
{
// Arrange - index points to a non-existent SKILL.md URI. Because the source builds
// skills from index info only, discovery still succeeds.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexWithoutSkillMdResource>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert - discovery succeeds from index alone.
var skill = Assert.Single(skills);
Assert.Equal("unit-converter", skill.Frontmatter.Name);
}
[Fact]
public async Task GetSkillsAsync_IndexEntryWithInvalidName_IsSkippedAsync()
{
// Arrange - index entry has an invalid (uppercase) name, which AgentSkillFrontmatter rejects.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexWithInvalidName>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_IndexEntryWithMissingRequiredFields_IsSkippedAsync()
{
// Arrange - index entry is missing the required description and url fields.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexWithIncompleteEntry>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_ArchiveEntryWithUnreadableResource_IsSkippedAsync()
{
// Arrange - index has an "archive" entry, but the referenced archive resource does not
// exist on the server, so reading it fails and the entry is skipped gracefully.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexWithArchiveOnly>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_IndexEntryWithTemplateType_IsSkippedAsync()
{
// Arrange - index has an "mcp-resource-template" entry (parameterized skill namespace).
// The current source skips template entries; they require user input to materialize.
await using var server = new InMemoryMcpServer(builder =>
builder.WithResources<IndexWithTemplateOnly>());
await using var client = await server.CreateClientAsync();
var source = new AgentMcpSkillsSource(client);
// Act
var skills = await source.GetSkillsAsync(TestAgentSkillsSourceContextFactory.Create());
// Assert
Assert.Empty(skills);
}
#region Resource classes (registered with the MCP server via WithResources<T>)
// CA1812 flags these classes as "never instantiated", which is technically correct -
// they are never constructed because they only contain static methods (e.g. `public static string Index()`).
// The MCP framework discovers and invokes these static methods via the [McpServerResourceType] and
// [McpServerResource] attributes registered through WithResources<T>(), without ever creating an instance.
#pragma warning disable CA1812
/// <summary>
/// Server type that exposes both <c>skill://index.json</c> and a single <c>skill-md</c> resource.
/// </summary>
[McpServerResourceType]
private sealed class IndexAndSkill
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => SampleSkillIndex;
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
public static string Skill() => SampleSkillMd;
}
/// <summary>Server type that exposes only <c>SKILL.md</c> (no index, no siblings).</summary>
[McpServerResourceType]
private sealed class SkillOnly
{
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
public static string Skill() => SampleSkillMd;
}
/// <summary>Server type that exposes <c>skill://index.json</c>, <c>SKILL.md</c>, and one text sibling.</summary>
[McpServerResourceType]
private sealed class IndexAndSkillWithSibling
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => SampleSkillIndex;
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
public static string Skill() => SampleSkillMd;
[McpServerResource(UriTemplate = "skill://unit-converter/references/checklist.md", Name = "checklist", MimeType = "text/markdown")]
public static string Checklist() => "- check thing 1\n- check thing 2";
}
/// <summary>Server type that exposes <c>skill://index.json</c>, <c>SKILL.md</c>, and one binary sibling.</summary>
[McpServerResourceType]
private sealed class IndexAndSkillWithBinarySibling
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => SampleSkillIndex;
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "unit-converter", MimeType = "text/markdown")]
public static string Skill() => SampleSkillMd;
[McpServerResource(UriTemplate = "skill://unit-converter/assets/icon.bin", Name = "icon", MimeType = "application/octet-stream")]
public static BlobResourceContents Icon() => BlobResourceContents.FromBytes(
new byte[] { 0x01, 0x02, 0x03, 0x04 },
"skill://unit-converter/assets/icon.bin",
"application/octet-stream");
}
/// <summary>Server type that exposes only the index (no concrete SKILL.md resource).</summary>
[McpServerResourceType]
private sealed class IndexWithoutSkillMdResource
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => SampleSkillIndex;
}
/// <summary>Server type whose index entry has an invalid (uppercase) name.</summary>
[McpServerResourceType]
private sealed class IndexWithInvalidName
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "UnitConverter",
"type": "skill-md",
"description": "Convert between common units.",
"url": "skill://UnitConverter/SKILL.md"
}
]
}
""";
}
/// <summary>Server type whose index entry is missing required fields (description, url).</summary>
[McpServerResourceType]
private sealed class IndexWithIncompleteEntry
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "unit-converter",
"type": "skill-md"
}
]
}
""";
}
/// <summary>Server type whose index references only an <c>archive</c> entry.</summary>
[McpServerResourceType]
private sealed class IndexWithArchiveOnly
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"name": "some-skill",
"type": "archive",
"description": "Packaged skill.",
"url": "skill://some-skill.tar.gz"
}
]
}
""";
}
/// <summary>Server type whose index references only an <c>mcp-resource-template</c> entry.</summary>
[McpServerResourceType]
private sealed class IndexWithTemplateOnly
{
[McpServerResource(UriTemplate = "skill://index.json", Name = "index", MimeType = "application/json")]
public static string Index() => """
{
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
"skills": [
{
"type": "mcp-resource-template",
"description": "Per-product documentation skill",
"url": "skill://docs/{product}/SKILL.md"
}
]
}
""";
}
#pragma warning restore CA1812
#endregion
}
@@ -0,0 +1,76 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO.Pipelines;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Skills.Mcp.UnitTests;
/// <summary>
/// Spins up an in-memory MCP server hosting a configurable set of resources, and returns an
/// <see cref="McpClient"/> connected to it. Disposes both ends together.
/// </summary>
[SuppressMessage("Performance", "CA1812:Avoid uninstantiated internal classes", Justification = "Instantiated by AgentMcpSkillsSourceTests which is temporarily excluded from compilation.")]
internal sealed class InMemoryMcpServer : IAsyncDisposable
{
private readonly Pipe _clientToServerPipe = new();
private readonly Pipe _serverToClientPipe = new();
private readonly CancellationTokenSource _cts = new();
private readonly ServiceProvider _serviceProvider;
private readonly Task _serverTask;
public InMemoryMcpServer(Action<IMcpServerBuilder> configure)
{
var services = new ServiceCollection();
services.AddLogging(builder => builder.AddProvider(NullLoggerProvider.Instance));
IMcpServerBuilder builder = services
.AddMcpServer()
.WithStreamServerTransport(
inputStream: this._clientToServerPipe.Reader.AsStream(),
outputStream: this._serverToClientPipe.Writer.AsStream());
configure(builder);
this._serviceProvider = services.BuildServiceProvider();
var server = this._serviceProvider.GetRequiredService<McpServer>();
this._serverTask = server.RunAsync(this._cts.Token);
}
public async Task<McpClient> CreateClientAsync(CancellationToken cancellationToken = default)
{
return await McpClient.CreateAsync(
new StreamClientTransport(
serverInput: this._clientToServerPipe.Writer.AsStream(),
serverOutput: this._serverToClientPipe.Reader.AsStream()),
cancellationToken: cancellationToken).ConfigureAwait(false);
}
public async ValueTask DisposeAsync()
{
await this._cts.CancelAsync().ConfigureAwait(false);
this._clientToServerPipe.Writer.Complete();
this._serverToClientPipe.Writer.Complete();
try
{
await this._serverTask.ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Expected when the server is cancelled during shutdown.
}
await this._serviceProvider.DisposeAsync().ConfigureAwait(false);
this._cts.Dispose();
}
}
@@ -0,0 +1,159 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using FluentAssertions;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
public class TaskAwareMcpClientAIFunctionTests
{
[Fact]
public async Task InvokeAsync_RequiredTool_HappyPath_ReturnsResultAsync()
{
// Arrange
McpServerPrimitiveCollection<McpServerTool> tools = [
TestTools.Create("req", ToolTaskSupport.Required, () => "required-result"),
];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction req = result.Single(f => f.Name == "req");
req.Should().BeOfType<TaskAwareMcpClientAIFunction>();
// Act
object? invokeResult = await req.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
JsonElement payload = invokeResult.Should().BeOfType<JsonElement>().Subject;
ExtractTextContent(payload).Should().Be("required-result");
}
[Fact]
public async Task InvokeAsync_PropagatesDefaultTimeToLiveAsync()
{
// Arrange — capture the request meta on the server so we can assert TTL flowed through.
TimeSpan? observedTtl = null;
McpServerTool tool = McpServerTool.Create(
(RequestContext<CallToolRequestParams> ctx) =>
{
observedTtl = ctx.Params?.Task?.TimeToLive;
return "ok";
},
new McpServerToolCreateOptions
{
Name = "ttl-tool",
Description = "Echoes the requested TTL.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
TimeSpan requestedTtl = TimeSpan.FromMinutes(7);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync(new McpTaskOptions { DefaultTimeToLive = requestedTtl });
AIFunction wrapped = result.Single();
// Act
_ = await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert
observedTtl.Should().Be(requestedTtl);
}
[Fact]
public async Task InvokeAsync_RespectsCancellationAsync()
{
// Arrange — a tool that never completes until it's cancelled.
var serverCancelled = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
McpServerTool tool = McpServerTool.Create(
async (CancellationToken ct) =>
{
try
{
await Task.Delay(Timeout.Infinite, ct);
}
catch (OperationCanceledException)
{
serverCancelled.TrySetResult(true);
throw;
}
return "should-not-complete";
},
new McpServerToolCreateOptions
{
Name = "blocking",
Description = "Blocks indefinitely until cancelled.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
using CancellationTokenSource cts = new();
// Act — start the invocation, cancel after a brief delay.
Task<object?> invocation = wrapped.InvokeAsync(arguments: null, cts.Token).AsTask();
await Task.Delay(200);
cts.Cancel();
// Assert — wrapper observes cancellation and signals server-side cancellation.
Func<Task> awaitInvocation = async () => await invocation;
await awaitInvocation.Should().ThrowAsync<OperationCanceledException>();
// Server-side handler should have observed cancellation as a result of the wrapper's
// tasks/cancel call (best-effort wait — give the server-loop a few seconds).
Task observedTask = serverCancelled.Task;
Task completed = await Task.WhenAny(observedTask, Task.Delay(TimeSpan.FromSeconds(5)));
completed.Should().BeSameAs(observedTask, "the wrapper should have issued tasks/cancel");
}
[Fact]
public async Task InvokeAsync_FailedTask_ThrowsInvalidOperationAsync()
{
// Arrange — a tool whose handler throws, which the server surfaces as a Failed task.
McpServerTool tool = McpServerTool.Create(
(Func<string>)(() => throw new InvalidOperationException("simulated tool failure")),
new McpServerToolCreateOptions
{
Name = "boom",
Description = "Throws unconditionally.",
Execution = new ToolExecution { TaskSupport = ToolTaskSupport.Required },
});
McpServerPrimitiveCollection<McpServerTool> tools = [tool];
await using InMemoryMcpServerFixture fixture = await InMemoryMcpServerFixture.CreateAsync(tools);
var result = await fixture.Client.ListAgentToolsWithTaskSupportAsync();
AIFunction wrapped = result.Single();
// Act
Func<Task> act = async () => await wrapped.InvokeAsync(arguments: null, CancellationToken.None);
// Assert — Phase 1 surfaces non-Completed terminal states as InvalidOperationException
// carrying the server's StatusMessage. (See PollAndRetrieveResultAsync.)
await act.Should().ThrowAsync<Exception>().Where(ex =>
ex is InvalidOperationException
|| ex.GetType().FullName == "ModelContextProtocol.McpException");
}
/// <summary>
/// Extracts the first text-content block from a serialized <c>CallToolResult</c>
/// (the JSON shape returned by the wrapper and by <c>McpClientTool.InvokeAsync</c>).
/// </summary>
private static string ExtractTextContent(JsonElement payload)
{
payload.ValueKind.Should().Be(JsonValueKind.Object);
JsonElement content = payload.GetProperty("content");
content.ValueKind.Should().Be(JsonValueKind.Array);
JsonElement firstBlock = content.EnumerateArray().First();
return firstBlock.GetProperty("text").GetString()!;
}
}
@@ -0,0 +1,34 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI;
internal static class TestAgentSkillsSourceContextFactory
{
public static AgentSkillsSourceContext Create(AIAgent? agent = null)
=> new(agent ?? new SkillsSourceTestAgent(), session: null);
private sealed class SkillsSourceTestAgent : AIAgent
{
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override Task<AgentResponse> RunCoreAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
protected override IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(IEnumerable<ChatMessage> messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default)
=> throw new NotSupportedException();
}
}
@@ -0,0 +1,30 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace Microsoft.Agents.AI.Mcp.UnitTests;
/// <summary>
/// Helpers to create <see cref="McpServerTool"/> instances with a specific
/// <see cref="ToolTaskSupport"/> level for in-memory fixtures.
/// </summary>
internal static class TestTools
{
public static McpServerTool Create(string name, ToolTaskSupport? taskSupport, Delegate handler)
{
McpServerToolCreateOptions options = new()
{
Name = name,
Description = $"Test tool {name}.",
};
if (taskSupport is ToolTaskSupport ts)
{
options.Execution = new ToolExecution { TaskSupport = ts };
}
return McpServerTool.Create(handler, options);
}
}