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
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:
@@ -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();
|
||||
}
|
||||
}
|
||||
+20
@@ -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>
|
||||
+928
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user