mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.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>
This commit is contained in:
committed by
GitHub
Unverified
parent
8fbda1de22
commit
1d301af7d2
@@ -117,17 +117,18 @@
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step03_ClassBasedSkills/Agent_Step03_ClassBasedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step04_MixedSkills/Agent_Step04_MixedSkills.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step05_SkillsWithDI/Agent_Step05_SkillsWithDI.csproj" />
|
||||
<Project Path="samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills/Agent_Step06_McpBasedSkills.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/Harness/">
|
||||
<File Path="samples/02-agents/Harness/README.md" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console/Harness_Shared_Console.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Shared_Console_OpenAI/Harness_Shared_Console_OpenAI.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step01_Research/Harness_Step01_Research.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step02_Research_WithBackgroundAgents/Harness_Step02_Research_WithBackgroundAgents.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step03_DataProcessing/Harness_Step03_DataProcessing.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/Harness_Step04_CodeExecution/Harness_Step04_CodeExecution.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveFramework/ConsoleReactiveFramework.csproj" />
|
||||
<Project Path="samples/02-agents/Harness/ConsoleReactiveComponents/ConsoleReactiveComponents.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Samples/02-agents/AGUI/Step05_StateManagement/">
|
||||
<Project Path="samples/02-agents/AGUI/Step05_StateManagement/Client/Client.csproj" />
|
||||
@@ -244,8 +245,8 @@
|
||||
<Project Path="samples/03-workflows/Declarative/GenerateCode/GenerateCode.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/HostedWorkflow/HostedWorkflow.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InputArguments/InputArguments.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFoundryToolboxMcp/InvokeFoundryToolboxMcp.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeFunctionTool/InvokeFunctionTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeHttpRequest/InvokeHttpRequest.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/InvokeMcpTool/InvokeMcpTool.csproj" />
|
||||
<Project Path="samples/03-workflows/Declarative/Marketing/Marketing.csproj" />
|
||||
@@ -597,8 +598,8 @@
|
||||
<Project Path="src/Microsoft.Agents.AI.DurableTask/Microsoft.Agents.AI.DurableTask.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry.Hosting/Microsoft.Agents.AI.Foundry.Hosting.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Foundry/Microsoft.Agents.AI.Foundry.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.GitHub.Copilot/Microsoft.Agents.AI.GitHub.Copilot.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Harness/Microsoft.Agents.AI.Harness.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A.AspNetCore/Microsoft.Agents.AI.Hosting.A2A.AspNetCore.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.A2A/Microsoft.Agents.AI.Hosting.A2A.csproj" />
|
||||
<Project Path="src/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore/Microsoft.Agents.AI.Hosting.AGUI.AspNetCore.csproj" />
|
||||
@@ -624,8 +625,8 @@
|
||||
<Project Path="tests/AnthropicChatCompletion.IntegrationTests/AnthropicChatCompletion.IntegrationTests.csproj" />
|
||||
<Project Path="tests/AzureAIAgentsPersistent.IntegrationTests/AzureAIAgentsPersistent.IntegrationTests.csproj" />
|
||||
<Project Path="tests/CopilotStudio.IntegrationTests/CopilotStudio.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests.TestContainer/Foundry.Hosting.IntegrationTests.TestContainer.csproj" />
|
||||
<Project Path="tests/Foundry.Hosting.IntegrationTests/Foundry.Hosting.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Foundry.IntegrationTests/Foundry.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.IntegrationTests/Microsoft.Agents.AI.DurableTask.IntegrationTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests/Microsoft.Agents.AI.GitHub.Copilot.IntegrationTests.csproj" />
|
||||
@@ -650,8 +651,8 @@
|
||||
<Project Path="tests/Microsoft.Agents.AI.Declarative.UnitTests/Microsoft.Agents.AI.Declarative.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DevUI.UnitTests/Microsoft.Agents.AI.DevUI.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.DurableTask.UnitTests/Microsoft.Agents.AI.DurableTask.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Foundry.UnitTests/Microsoft.Agents.AI.Foundry.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests/Microsoft.Agents.AI.GitHub.Copilot.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Harness.UnitTests/Microsoft.Agents.AI.Harness.UnitTests.csproj" />
|
||||
<Project Path="tests/Microsoft.Agents.AI.Hosting.A2A.UnitTests/Microsoft.Agents.AI.Hosting.A2A.UnitTests.csproj" />
|
||||
|
||||
@@ -363,6 +363,25 @@ internal static class AgentsSamples
|
||||
],
|
||||
},
|
||||
|
||||
new SampleDefinition
|
||||
{
|
||||
Name = "Agent_Step06_McpBasedSkills",
|
||||
ProjectPath = "samples/02-agents/AgentSkills/Agent_Step06_McpBasedSkills",
|
||||
RequiredEnvironmentVariables = ["AZURE_OPENAI_ENDPOINT"],
|
||||
OptionalEnvironmentVariables = ["AZURE_OPENAI_DEPLOYMENT_NAME"],
|
||||
MustContain =
|
||||
[
|
||||
"Discovering MCP-based skills",
|
||||
"Agent:",
|
||||
],
|
||||
ExpectedOutputDescription =
|
||||
[
|
||||
"The output should show the agent converting 26.2 miles to kilometers and 75 kilograms to pounds.",
|
||||
"The response should contain approximate numeric values for both conversions.",
|
||||
"The output should not contain error messages or stack traces.",
|
||||
],
|
||||
},
|
||||
|
||||
// ── AgentWithMemory ─────────────────────────────────────────────────
|
||||
|
||||
new SampleDefinition
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFrameworks>net10.0</TargetFrameworks>
|
||||
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<NoWarn>$(NoWarn);MAAI001;MCPEXP001</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Azure.AI.OpenAI" />
|
||||
<PackageReference Include="Azure.Identity" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Mcp\Microsoft.Agents.AI.Mcp.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.OpenAI\Microsoft.Agents.AI.OpenAI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,142 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
// This sample demonstrates how to discover Agent Skills served over MCP.
|
||||
//
|
||||
// When launched with "--server", this executable runs a small MCP stdio server
|
||||
// that exposes a unit-converter skill via the SEP-2640 convention:
|
||||
// - skill://index.json — discovery document listing all skills
|
||||
// - skill://unit-converter/SKILL.md — the skill instructions
|
||||
//
|
||||
// In default (client) mode the sample launches itself as a child process,
|
||||
// connects via StdioClientTransport, and uses AgentSkillsProviderBuilder
|
||||
// to discover and inject the skill into a ChatClientAgent.
|
||||
|
||||
using System.ComponentModel;
|
||||
using Azure.AI.OpenAI;
|
||||
using Azure.Identity;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Server;
|
||||
using OpenAI.Responses;
|
||||
|
||||
if (args.Length > 0 && args[0] == "--server")
|
||||
{
|
||||
await RunMcpServerAsync();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Configuration ---
|
||||
string openAiEndpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
|
||||
?? throw new InvalidOperationException("AZURE_OPENAI_ENDPOINT is not set.");
|
||||
string deploymentName = Environment.GetEnvironmentVariable("AZURE_OPENAI_DEPLOYMENT_NAME") ?? "gpt-5.4-mini";
|
||||
|
||||
// --- MCP client + skill discovery ---
|
||||
// Launch this same assembly as a stdio MCP server in a child process.
|
||||
var thisAssemblyPath = typeof(Program).Assembly.Location;
|
||||
Console.WriteLine("Discovering MCP-based skills");
|
||||
|
||||
await using McpClient client = await McpClient.CreateAsync(
|
||||
new StdioClientTransport(new()
|
||||
{
|
||||
Name = "skills-server",
|
||||
Command = "dotnet",
|
||||
Arguments = [thisAssemblyPath, "--server"],
|
||||
}));
|
||||
|
||||
var skillsProvider = new AgentSkillsProviderBuilder()
|
||||
.UseMcpSkills(client)
|
||||
.Build();
|
||||
|
||||
// --- Agent ---
|
||||
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
|
||||
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
|
||||
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
|
||||
AIAgent agent = new AzureOpenAIClient(new Uri(openAiEndpoint), new DefaultAzureCredential())
|
||||
.GetResponsesClient()
|
||||
.AsAIAgent(new ChatClientAgentOptions
|
||||
{
|
||||
Name = "SkillsAgent",
|
||||
ChatOptions = new()
|
||||
{
|
||||
Instructions = "You are a helpful assistant. Use available skills to answer the user.",
|
||||
},
|
||||
AIContextProviders = [skillsProvider],
|
||||
},
|
||||
model: deploymentName);
|
||||
|
||||
// --- Run ---
|
||||
Console.WriteLine(new string('-', 60));
|
||||
|
||||
AgentResponse response = await agent.RunAsync(
|
||||
"How many kilometers is a marathon (26.2 miles)? And how many pounds is 75 kilograms?");
|
||||
|
||||
Console.WriteLine($"Agent: {response.Text}");
|
||||
|
||||
// --- Server mode (launched as a child process via --server) ---------------------------------
|
||||
static async Task RunMcpServerAsync()
|
||||
{
|
||||
var builder = Host.CreateApplicationBuilder();
|
||||
|
||||
// Critical for stdio transport: any provider that writes to stdout will corrupt the
|
||||
// JSON-RPC channel. Clear all providers; the MCP SDK routes its own diagnostics
|
||||
// appropriately.
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole(o => o.LogToStandardErrorThreshold = LogLevel.Trace);
|
||||
|
||||
builder.Services.AddMcpServer(o => o.ServerInfo = new() { Name = "SkillsServer", Version = "1.0.0" })
|
||||
.WithStdioServerTransport()
|
||||
.WithResources<SkillResources>();
|
||||
|
||||
await builder.Build().RunAsync();
|
||||
}
|
||||
|
||||
#pragma warning disable CA1812 // Discovered by MCP SDK via [McpServerResourceType] attribute
|
||||
[McpServerResourceType]
|
||||
internal sealed class SkillResources
|
||||
#pragma warning restore CA1812
|
||||
{
|
||||
private const string IndexJson = """
|
||||
{
|
||||
"$schema": "https://schemas.agentskills.io/discovery/0.2.0/schema.json",
|
||||
"skills": [
|
||||
{
|
||||
"name": "unit-converter",
|
||||
"type": "skill-md",
|
||||
"description": "Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.",
|
||||
"url": "skill://unit-converter/SKILL.md"
|
||||
}
|
||||
]
|
||||
}
|
||||
""";
|
||||
|
||||
private const string SkillMd = """
|
||||
---
|
||||
name: unit-converter
|
||||
description: Convert between common units using a multiplication factor. Use when asked to convert miles, kilometers, pounds, or kilograms.
|
||||
---
|
||||
|
||||
## Usage
|
||||
|
||||
When the user requests a unit conversion, use these factors:
|
||||
|
||||
| From | To | Factor |
|
||||
|-------------|-------------|----------|
|
||||
| miles | kilometers | 1.60934 |
|
||||
| kilometers | miles | 0.621371 |
|
||||
| pounds | kilograms | 0.453592 |
|
||||
| kilograms | pounds | 2.20462 |
|
||||
|
||||
Formula: result = value × factor
|
||||
""";
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://index.json", Name = "Skill Index", MimeType = "application/json")]
|
||||
[Description("SEP-2640 skill discovery index")]
|
||||
public static string GetIndex() => IndexJson;
|
||||
|
||||
[McpServerResource(UriTemplate = "skill://unit-converter/SKILL.md", Name = "Unit Converter Skill", MimeType = "text/markdown")]
|
||||
[Description("Unit converter skill instructions")]
|
||||
public static string GetSkillMd() => SkillMd;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# MCP-Based Agent Skills Sample
|
||||
|
||||
This sample demonstrates how to discover **Agent Skills served over MCP** with a `ChatClientAgent`.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
- Hosting a small MCP server (in this same executable, launched with `--server`) that
|
||||
exposes skill resources following the SEP-2640 convention.
|
||||
- Connecting an `McpClient` to the embedded server via stdio transport.
|
||||
- Building an `AgentSkillsProvider` via `UseMcpSkills(client)`, which reads
|
||||
`skill://index.json` (SEP-2640 canonical discovery) and constructs skills from the
|
||||
index entries.
|
||||
- The progressive disclosure pattern across MCP: advertise → load → read resources, exactly
|
||||
as for filesystem-backed skills.
|
||||
|
||||
## Running the Sample
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- .NET 10.0 SDK
|
||||
- Azure OpenAI endpoint with a deployed model
|
||||
|
||||
### Setup
|
||||
|
||||
```powershell
|
||||
$env:AZURE_OPENAI_ENDPOINT="https://your-endpoint.openai.azure.com/"
|
||||
$env:AZURE_OPENAI_DEPLOYMENT_NAME="gpt-5.4-mini"
|
||||
```
|
||||
|
||||
### Run
|
||||
|
||||
```powershell
|
||||
dotnet run
|
||||
```
|
||||
@@ -9,6 +9,7 @@ Samples demonstrating Agent Skills capabilities. Each sample shows a different w
|
||||
| [Agent_Step03_ClassBasedSkills](Agent_Step03_ClassBasedSkills/) | Define skills as C# classes using `AgentClassSkill`. |
|
||||
| [Agent_Step04_MixedSkills](Agent_Step04_MixedSkills/) | **(Advanced)** Combine file-based, code-defined, and class-based skills using `AgentSkillsProviderBuilder`. |
|
||||
| [Agent_Step05_SkillsWithDI](Agent_Step05_SkillsWithDI/) | Use Dependency Injection with both code-defined (`AgentInlineSkill`) and class-based (`AgentClassSkill`) skills. |
|
||||
| [Agent_Step06_McpBasedSkills](Agent_Step06_McpBasedSkills/) | Discover skills served over the [Model Context Protocol (MCP)](https://modelcontextprotocol.io) via `AgentMcpSkillsSource`. Spins up an in-process MCP server that exposes skills as resources (`skill://...`) and connects an `McpClient` to it. |
|
||||
|
||||
## Key Concepts
|
||||
|
||||
|
||||
@@ -4,13 +4,15 @@
|
||||
<TargetFrameworks>$(TargetFrameworksCore)</TargetFrameworks>
|
||||
<RootNamespace>Microsoft.Agents.AI.Mcp</RootNamespace>
|
||||
<VersionSuffix>alpha</VersionSuffix>
|
||||
<NoWarn>$(NoWarn);MEAI001;MCPEXP001</NoWarn>
|
||||
<NoWarn>$(NoWarn);MEAI001;MAAI001;MCPEXP001</NoWarn>
|
||||
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(RepoRoot)/dotnet/nuget/nuget-package.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<InjectSharedThrow>true</InjectSharedThrow>
|
||||
<InjectSharedDiagnosticIds>true</InjectSharedDiagnosticIds>
|
||||
<InjectTrimAttributesOnLegacy>true</InjectTrimAttributesOnLegacy>
|
||||
</PropertyGroup>
|
||||
|
||||
@@ -34,4 +36,8 @@
|
||||
<InternalsVisibleTo Include="Microsoft.Agents.AI.Mcp.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkill"/> discovered from an MCP server exposing the Agent Skills convention.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// The skill is constructed from <c>skill://index.json</c> discovery metadata only; <see cref="GetContentAsync"/>
|
||||
/// fetches the full <c>SKILL.md</c> content from the MCP server on demand via <c>resources/read</c>.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Per SEP-2640, resources referenced inside SKILL.md are fetched on demand via the originating MCP
|
||||
/// server: <see cref="GetResourceAsync"/> resolves a relative resource name against the
|
||||
/// skill's root URI, issues a <c>resources/read</c> request, and returns an <see cref="AgentMcpSkillResource"/>
|
||||
/// with pre-fetched content.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class AgentMcpSkill : AgentSkill
|
||||
{
|
||||
private const string SkillMdSuffix = "SKILL.md";
|
||||
|
||||
private readonly McpClient _client;
|
||||
private readonly string _skillMdUri;
|
||||
private readonly string _skillRootUri;
|
||||
private string? _content;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkill"/> class.
|
||||
/// </summary>
|
||||
/// <param name="frontmatter">The parsed frontmatter metadata for this skill.</param>
|
||||
/// <param name="skillMdUri">
|
||||
/// The full MCP resource URI of the <c>SKILL.md</c> resource (e.g. <c>skill://unit-converter/SKILL.md</c>).
|
||||
/// Used by <see cref="GetContentAsync"/> to fetch the skill content on demand. The skill's root URI
|
||||
/// (used to resolve sibling resources) is derived by stripping the trailing <c>SKILL.md</c> segment.
|
||||
/// </param>
|
||||
/// <param name="client">The MCP client used to fetch resources on demand.</param>
|
||||
public AgentMcpSkill(AgentSkillFrontmatter frontmatter, string skillMdUri, McpClient client)
|
||||
{
|
||||
this.Frontmatter = Throw.IfNull(frontmatter);
|
||||
this._skillMdUri = Throw.IfNullOrWhitespace(skillMdUri);
|
||||
this._skillRootUri = ComputeSkillRootUri(skillMdUri);
|
||||
this._client = Throw.IfNull(client);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override AgentSkillFrontmatter Frontmatter { get; }
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Fetches the <c>SKILL.md</c> content from the MCP server via <c>resources/read</c> on the first call
|
||||
/// and caches the result.
|
||||
/// </remarks>
|
||||
public override async ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (this._content is not null)
|
||||
{
|
||||
return this._content;
|
||||
}
|
||||
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
ReadResourceResult result = await this._client.ReadResourceAsync(this._skillMdUri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
|
||||
string text = string.Join("\n", result.Contents.OfType<TextResourceContents>().Select(c => c.Text));
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"The MCP server returned no text content for SKILL.md resource '{this._skillMdUri}'.");
|
||||
}
|
||||
|
||||
return this._content = text;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <remarks>
|
||||
/// Resolves <paramref name="name"/> as a relative path against the skill's root URI, issues a
|
||||
/// <c>resources/read</c> request to the MCP server, and returns an <see cref="AgentMcpSkillResource"/>
|
||||
/// with the pre-fetched content. Returns <see langword="null"/> when the name is empty, the server
|
||||
/// returns no content, or the resource does not exist on the server.
|
||||
/// </remarks>
|
||||
public override async ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
string uri = this._skillRootUri + name;
|
||||
|
||||
ReadResourceResult result;
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
result = await this._client.ReadResourceAsync(uri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentMcpSkillResource(name: name, result: result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Strips the trailing <c>SKILL.md</c> from the URI to produce the skill's root directory URI.
|
||||
/// If the URI doesn't end with <c>SKILL.md</c>, ensures it ends with a trailing slash.
|
||||
/// </summary>
|
||||
private static string ComputeSkillRootUri(string skillMdUri)
|
||||
{
|
||||
if (skillMdUri.EndsWith(SkillMdSuffix, StringComparison.Ordinal))
|
||||
{
|
||||
return skillMdUri.Substring(0, skillMdUri.Length - SkillMdSuffix.Length);
|
||||
}
|
||||
|
||||
if (skillMdUri.EndsWith("/", StringComparison.Ordinal))
|
||||
{
|
||||
return skillMdUri;
|
||||
}
|
||||
|
||||
return skillMdUri + "/";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.AI;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkillResource"/> backed by content fetched from an MCP server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <see cref="ReadResourceResult"/> is fetched eagerly by <see cref="AgentMcpSkill.GetResourceAsync"/>
|
||||
/// at construction time; <see cref="ReadAsync"/> extracts the content from the result.
|
||||
/// </remarks>
|
||||
internal sealed class AgentMcpSkillResource : AgentSkillResource
|
||||
{
|
||||
private readonly ReadResourceResult _result;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkillResource"/> class with a pre-fetched result.
|
||||
/// </summary>
|
||||
/// <param name="name">The resource name (e.g. a relative path or identifier).</param>
|
||||
/// <param name="result">The result returned by the MCP server's <c>resources/read</c> request.</param>
|
||||
/// <param name="description">An optional description of the resource.</param>
|
||||
public AgentMcpSkillResource(string name, ReadResourceResult result, string? description = null)
|
||||
: base(Throw.IfNullOrWhitespace(name), description)
|
||||
{
|
||||
this._result = Throw.IfNull(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
/// <returns>
|
||||
/// A <see cref="DataContent"/> when the resource contains binary content, a <see cref="string"/> when
|
||||
/// it contains text, or <see langword="null"/> when the server returned no content blocks.
|
||||
/// </returns>
|
||||
public override Task<object?> ReadAsync(IServiceProvider? serviceProvider = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
BlobResourceContents? blob = this._result.Contents.OfType<BlobResourceContents>().FirstOrDefault();
|
||||
if (blob is not null)
|
||||
{
|
||||
return Task.FromResult<object?>(blob.ToAIContent());
|
||||
}
|
||||
|
||||
string text = string.Join("\n", this._result.Contents.OfType<TextResourceContents>().Select(c => c.Text));
|
||||
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return Task.FromResult<object?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<object?>(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Client;
|
||||
using ModelContextProtocol.Protocol;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// An <see cref="AgentSkillsSource"/> that discovers Agent Skills served over the Model Context Protocol (MCP).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Discovery follows the SEP-2640 recommended approach: the source reads the well-known
|
||||
/// <c>skill://index.json</c> resource and constructs one <see cref="AgentSkill"/> per
|
||||
/// <c>skill-md</c> entry directly from the entry's <c>name</c>, <c>description</c>, and <c>url</c> fields.
|
||||
/// The referenced <c>SKILL.md</c> resource is not read during discovery; hosts fetch its body on
|
||||
/// demand via <c>resources/read</c> against the URI exposed on the resulting skill.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only index entries of type <c>skill-md</c> are supported at the moment; entries of any other
|
||||
/// type are skipped.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <c>skill://index.json</c> is absent, unreadable, empty, or fails to parse, this source
|
||||
/// returns an empty list. Discovered skills serve their referenced resources on demand via
|
||||
/// <see cref="AgentSkill.GetResourceAsync"/>; they do not enumerate sibling files up front.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed partial class AgentMcpSkillsSource : AgentSkillsSource
|
||||
{
|
||||
/// <summary>
|
||||
/// SEP-2640 canonical discovery document URI.
|
||||
/// </summary>
|
||||
private const string IndexUri = "skill://index.json";
|
||||
|
||||
private const string SkillMdEntryType = "skill-md";
|
||||
|
||||
private readonly McpClient _client;
|
||||
private readonly ILogger _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="AgentMcpSkillsSource"/> class.
|
||||
/// </summary>
|
||||
/// <param name="client">An MCP client connected to a server that exposes Agent Skills resources.</param>
|
||||
/// <param name="loggerFactory">Optional logger factory.</param>
|
||||
public AgentMcpSkillsSource(McpClient client, ILoggerFactory? loggerFactory = null)
|
||||
{
|
||||
this._client = Throw.IfNull(client);
|
||||
this._logger = (loggerFactory ?? NullLoggerFactory.Instance).CreateLogger<AgentMcpSkillsSource>();
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public override async Task<IList<AgentSkill>> GetSkillsAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
McpSkillIndex? index = await this.TryReadIndexAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
var skills = new List<AgentSkill>();
|
||||
|
||||
foreach (var entry in index?.Skills ?? [])
|
||||
{
|
||||
if (this.TryCreateSkill(entry, out AgentMcpSkill? skill, out string skipReason))
|
||||
{
|
||||
skills.Add(skill);
|
||||
LogSkillLoaded(this._logger, skill.Frontmatter.Name);
|
||||
}
|
||||
else
|
||||
{
|
||||
LogIndexEntrySkipped(this._logger, entry.Name ?? "(unnamed)", skipReason);
|
||||
}
|
||||
}
|
||||
|
||||
LogSkillsLoadedTotal(this._logger, skills.Count);
|
||||
|
||||
return skills;
|
||||
}
|
||||
|
||||
private async Task<McpSkillIndex?> TryReadIndexAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
ReadResourceResult result;
|
||||
|
||||
try
|
||||
{
|
||||
#pragma warning disable CA2234 // Pass system uri objects instead of strings
|
||||
result = await this._client.ReadResourceAsync(IndexUri, cancellationToken: cancellationToken).ConfigureAwait(false);
|
||||
#pragma warning restore CA2234 // Pass system uri objects instead of strings
|
||||
}
|
||||
catch (McpException ex) when (ex is McpProtocolException pex && pex.ErrorCode == McpErrorCode.ResourceNotFound)
|
||||
{
|
||||
LogIndexAbsent(this._logger, ex.Message);
|
||||
return null;
|
||||
}
|
||||
catch (McpException ex)
|
||||
{
|
||||
LogIndexReadFailed(this._logger, ex);
|
||||
return null;
|
||||
}
|
||||
|
||||
string? indexText = result.Contents.OfType<TextResourceContents>().FirstOrDefault()?.Text;
|
||||
if (string.IsNullOrWhiteSpace(indexText))
|
||||
{
|
||||
LogIndexEmpty(this._logger);
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(indexText, McpJsonContext.Default.McpSkillIndex);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
LogIndexParseFailed(this._logger, ex);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryCreateSkill(
|
||||
McpSkillIndexEntry entry,
|
||||
[NotNullWhen(true)] out AgentMcpSkill? skill,
|
||||
out string skipReason)
|
||||
{
|
||||
skill = null;
|
||||
|
||||
if (!string.Equals(entry.Type, SkillMdEntryType, StringComparison.Ordinal))
|
||||
{
|
||||
skipReason = $"unsupported type '{entry.Type ?? "(none)"}'";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(entry.Url))
|
||||
{
|
||||
skipReason = "missing required 'url' field";
|
||||
return false;
|
||||
}
|
||||
|
||||
AgentSkillFrontmatter frontmatter;
|
||||
try
|
||||
{
|
||||
frontmatter = new AgentSkillFrontmatter(entry.Name!, entry.Description!);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
skipReason = $"invalid metadata: {ex.Message}";
|
||||
return false;
|
||||
}
|
||||
|
||||
skill = new AgentMcpSkill(frontmatter, entry.Url!, this._client);
|
||||
skipReason = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Loaded MCP skill: {SkillName}")]
|
||||
private static partial void LogSkillLoaded(ILogger logger, string skillName);
|
||||
|
||||
[LoggerMessage(LogLevel.Information, "Successfully loaded {Count} skills from MCP server")]
|
||||
private static partial void LogSkillsLoadedTotal(ILogger logger, int count);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "No skill://index.json resource available on MCP server: {Reason}")]
|
||||
private static partial void LogIndexAbsent(ILogger logger, string reason);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Failed to read skill://index.json from MCP server.")]
|
||||
private static partial void LogIndexReadFailed(ILogger logger, Exception exception);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "skill://index.json on MCP server returned empty/non-text contents")]
|
||||
private static partial void LogIndexEmpty(ILogger logger);
|
||||
|
||||
[LoggerMessage(LogLevel.Warning, "Failed to parse skill://index.json JSON document.")]
|
||||
private static partial void LogIndexParseFailed(ILogger logger, Exception exception);
|
||||
|
||||
[LoggerMessage(LogLevel.Debug, "Skipping skill index entry '{SkillName}': {Reason}")]
|
||||
private static partial void LogIndexEntrySkipped(ILogger logger, string skillName, string reason);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using Microsoft.Shared.Diagnostics;
|
||||
using ModelContextProtocol.Client;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// MCP-specific extension methods for <see cref="AgentSkillsProviderBuilder"/>.
|
||||
/// </summary>
|
||||
public static class AgentSkillsProviderBuilderMcpExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds a skill source that discovers skills served over MCP via the supplied <paramref name="client"/>.
|
||||
/// </summary>
|
||||
/// <param name="builder">The builder to extend.</param>
|
||||
/// <param name="client">An MCP client connected to a server exposing Agent Skills resources.</param>
|
||||
/// <returns>The builder instance for chaining.</returns>
|
||||
public static AgentSkillsProviderBuilder UseMcpSkills(this AgentSkillsProviderBuilder builder, McpClient client)
|
||||
{
|
||||
_ = Throw.IfNull(builder);
|
||||
_ = Throw.IfNull(client);
|
||||
|
||||
return builder.UseSource(new AgentMcpSkillsSource(client));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// Source-generated JSON context for MCP-skills well-known DTOs.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, AllowTrailingCommas = true, ReadCommentHandling = JsonCommentHandling.Skip)]
|
||||
[JsonSerializable(typeof(McpSkillIndex))]
|
||||
[JsonSerializable(typeof(McpSkillIndexEntry))]
|
||||
internal sealed partial class McpJsonContext : JsonSerializerContext;
|
||||
@@ -0,0 +1,96 @@
|
||||
// Copyright (c) Microsoft. All rights reserved.
|
||||
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Microsoft.Agents.AI;
|
||||
|
||||
/// <summary>
|
||||
/// DTO for the skill discovery index document served at <c>skill://index.json</c>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Schema reference: <see href="https://schemas.agentskills.io/discovery/0.2.0/schema.json"/>
|
||||
/// (Agent Skills Discovery v0.2.0), as bound to MCP by SEP-2640. The MCP binding differs from the
|
||||
/// base schema in two ways: the <c>url</c> field contains a full MCP resource URI, and the
|
||||
/// <c>digest</c> field is omitted (integrity is the transport's concern over an authenticated
|
||||
/// MCP connection).
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// All properties are nullable so that deserialization succeeds even when the server-side index
|
||||
/// is incomplete or malformed; callers MUST validate required fields before use.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
internal sealed class McpSkillIndex
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the opaque schema identifier URI. Required by the base schema; clients SHOULD
|
||||
/// match this against known schema URIs (e.g.
|
||||
/// <c>https://schemas.agentskills.io/discovery/0.2.0/schema.json</c>) before processing the index.
|
||||
/// </summary>
|
||||
[JsonPropertyName("$schema")]
|
||||
public string? Schema { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the array of skill entries. Required by the schema; an empty or missing
|
||||
/// <c>skills</c> array means the index advertises no skills.
|
||||
/// </summary>
|
||||
[JsonPropertyName("skills")]
|
||||
public List<McpSkillIndexEntry>? Skills { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A single entry in the skill discovery index.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Field requirements per the v0.2.0 schema and the SEP-2640 binding:
|
||||
/// <list type="bullet">
|
||||
/// <item><description><c>type</c>, <c>description</c>, and <c>url</c> are REQUIRED.</description></item>
|
||||
/// <item><description><c>name</c> is REQUIRED for <c>skill-md</c> and <c>archive</c> entries; OMITTED for <c>mcp-resource-template</c>.</description></item>
|
||||
/// <item><description><c>digest</c> is part of the base schema but OMITTED under the SEP-2640 MCP binding; carried here for compatibility with non-MCP indices.</description></item>
|
||||
/// </list>
|
||||
/// All properties are nullable to keep deserialization lenient; callers validate required fields before use.
|
||||
/// </remarks>
|
||||
internal sealed class McpSkillIndexEntry
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the skill name (1-64 chars, lowercase alphanumeric and hyphens; no leading,
|
||||
/// trailing, or consecutive hyphens). Required for <c>skill-md</c> and <c>archive</c> entries;
|
||||
/// omitted for <c>mcp-resource-template</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("name")]
|
||||
public string? Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the entry distribution type. Required. Schema-defined values are
|
||||
/// <c>skill-md</c> and <c>archive</c>; the SEP-2640 MCP binding additionally defines
|
||||
/// <c>mcp-resource-template</c>.
|
||||
/// </summary>
|
||||
[JsonPropertyName("type")]
|
||||
public string? Type { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the skill description (max 1024 chars per the Agent Skills specification).
|
||||
/// Required. For <c>skill-md</c> entries, SHOULD match the <c>description</c> in the skill's
|
||||
/// <c>SKILL.md</c> frontmatter.
|
||||
/// </summary>
|
||||
[JsonPropertyName("description")]
|
||||
public string? Description { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the artifact URL. Required. For <c>skill-md</c>, points at the
|
||||
/// <c>SKILL.md</c> resource. For <c>archive</c>, points at the archive file. For
|
||||
/// <c>mcp-resource-template</c>, an RFC 6570 URI template that resolves to a <c>SKILL.md</c>
|
||||
/// resource URI.
|
||||
/// </summary>
|
||||
[JsonPropertyName("url")]
|
||||
public string? Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the SHA-256 digest of the artifact bytes (e.g. <c>sha256:abcd1234...</c>).
|
||||
/// Required by the base v0.2.0 schema, but OMITTED under the SEP-2640 MCP binding because
|
||||
/// integrity is the transport's concern over an authenticated MCP connection.
|
||||
/// </summary>
|
||||
[JsonPropertyName("digest")]
|
||||
public string? Digest { get; set; }
|
||||
}
|
||||
+2
@@ -7,6 +7,8 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging" />
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" />
|
||||
<PackageReference Include="ModelContextProtocol" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,392 @@
|
||||
// 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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user