Files
agent-framework/dotnet/tests/Microsoft.Agents.AI.Mcp.UnitTests/Skills/AgentMcpSkillsSourceTests.cs
1d301af7d2 .NET: Add MCP-based skills support (skill-md type) (#6108)
* Add MCP-based skills support

- Add AgentMcpSkill, AgentMcpSkillResource, AgentMcpSkillsSource, and McpSkillIndex to Microsoft.Agents.AI.Mcp
- Add AgentSkillsProviderBuilderMcpExtensions for DI integration
- Add Agent_Step06_McpBasedSkills sample project
- Add unit tests for AgentMcpSkillsSource
- Update solution file and project references

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Remove unnecessary [Experimental] attributes from MCP package

The package is already alpha, so the [Experimental] attribute is redundant.
Removed from both AgentSkillsProviderBuilderMcpExtensions and
AgentMcpSkillsSource classes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Make Agent_Step06_McpBasedSkills self-contained and add to verify-samples

Embed an internal MCP server (launched via --server flag as a child process)
that serves skill://index.json and skill://unit-converter/SKILL.md resources,
replacing the external MCP_SKILLS_ENDPOINT dependency. The sample now uses
StdioClientTransport and a fixed prompt instead of an interactive loop.

Added SampleDefinition to AgentsSamples.cs for automated verification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Sort usings

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-27 18:38:57 +00:00

393 lines
15 KiB
C#

// 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();
// 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();
// 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());
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());
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());
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());
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();
// 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();
// 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();
// Assert
Assert.Empty(skills);
}
[Fact]
public async Task GetSkillsAsync_IndexEntryWithUnsupportedType_IsSkippedAsync()
{
// Arrange — index has an "archive" entry, which this source does not support.
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();
// 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();
// 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
}