Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@

var skillsProvider = new AgentSkillsProviderBuilder()
.UseMcpSkills(client)
.DisableCaching()
.Build();

// --- Agent ---
Expand Down Expand Up @@ -168,6 +169,8 @@ 3. Present the converted value clearly with both units
private static readonly string s_skillsListJson = $$"""
{
"resultType": "complete",
"ttlMs": 300000,
"cacheScope": "public",
"skills": [
{
"uri": "{{SkillUri}}",
Expand Down Expand Up @@ -205,12 +208,22 @@ private static string ComputeDigest(string content) =>
/// </summary>
[McpServerResource(UriTemplate = SkillUri, Name = "Unit Converter Skill", MimeType = "text/markdown")]
[Description("Unit converter skill instructions")]
public static string GetSkillMd() => SkillMd;
public static ReadResourceResult GetSkillMd() => new()
{
Contents = [new TextResourceContents { Uri = SkillUri, Text = SkillMd, MimeType = "text/markdown" }],
TimeToLive = TimeSpan.FromMinutes(5),
CacheScope = CacheScope.Public,
};

/// <summary>
/// Returns the conversion factors used by the unit-converter skill.
/// </summary>
[McpServerResource(UriTemplate = ConversionTableUri, Name = "Conversion Table", MimeType = "text/markdown")]
[Description("Unit conversion factors")]
public static string GetConversionTable() => ConversionTable;
public static ReadResourceResult GetConversionTable() => new()
{
Contents = [new TextResourceContents { Uri = ConversionTableUri, Text = ConversionTable, MimeType = "text/markdown" }],
TimeToLive = TimeSpan.FromMinutes(5),
CacheScope = CacheScope.Public,
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,81 @@ still applies.

## Security Considerations

### Sharing a source across authorization contexts

According to the MCP specification, each `skills/list`, `skills/get`, and `resources/read` response must include
a `cacheScope` field that tells the client who may reuse the cached response:

- `public` - the response is the same for every caller and may be shared between them.
- `private` - the response is specific to the caller's credentials (for example, a user or tenant)
and must not be shared with other callers.

Not all servers follow the specification yet, so a missing or `null` `cacheScope` is treated as `public`.

To keep private responses from leaking between callers, the source needs to know who the current caller is.
By default, it doesn't: all callers share one cache, so the source accepts only public responses and throws
`InvalidOperationException` for a private one. Each response is checked on its own, so a skill from a public
list can still fail if its `SKILL.md` or another file is returned as private.

To accept private responses, set `CacheIsolationKeySelector` to return a key for the current caller, such as
a user or tenant ID. Each key gets its own cache partition, so cached skills are never shared between keys.
Set it whenever the server can return private responses, even if your app has only one user:

```csharp
var options = new AgentMcpSkillsSourceOptions
{
CacheIsolationKeySelector = (context, _) => new ValueTask<string>(GetApplicationAuthorizationKey(context)),
MaxCachedPartitions = 1000,
PartitionSlidingExpiration = TimeSpan.FromMinutes(30),
PartitionAbsoluteExpiration = TimeSpan.FromHours(8),
};

using var provider = new AgentSkillsProviderBuilder()
.UseMcpSkills(sharedMcpClient, options)
.DisableCaching()
Comment thread
SergeyMenshykh marked this conversation as resolved.
Resolved
.Build();
```

The builder adds a separate cache for the resolved skill list. `CacheIsolationKeySelector` above
configures only the MCP source's internal caches, not the builder's cache. By default, the builder's
cache can return one caller's skills to another without invoking the MCP source or its key selector.
`DisableCaching()` removes only that outer cache; the MCP source still caches skills and resources
within each partition. Every call reaches the source so it can select the caller's partition and
check MCP response expiration.

`GetApplicationAuthorizationKey` is your own helper, not a framework API. It should return a non-empty ID
for the caller whose credentials the MCP client sends, for example a user or tenant ID.
Use an opaque ID, not an access token.

If resolving the key requires asynchronous work, the selector can await it and use the supplied
cancellation token:

```csharp
options.CacheIsolationKeySelector = async (context, cancellationToken) =>
await ResolveAuthorizationKeyAsync(context, cancellationToken);
```

`ResolveAuthorizationKeyAsync` is an application helper, just like `GetApplicationAuthorizationKey`.

The source only separates caches; it does not handle authentication. Your app is responsible for:

- configuring the MCP client to send the current caller's credentials;
- keeping the same caller's credentials when a returned skill later reads `SKILL.md` or other files;
- not sharing returned skills between callers.

By default, the source keeps up to 100 cache partitions. Adjust the limit or configure expiration
to control how many partitions are kept and for how long:

- `MaxCachedPartitions` - the maximum number of partitions to keep. Defaults to 100; set to `null` for no partition-count limit or `0` to disable partition caching. When full, expired and least recently used partitions are removed to make room for a new key.
- `PartitionSlidingExpiration` - removes a partition that has not been used for this long.
- `PartitionAbsoluteExpiration` - removes a partition this long after it was created, even if it is still in use.

When a partition is removed, the next call for its key fetches skills from the server again.
Skills that were already returned keep working. These options don't change how long MCP responses
are cached; use `TtlResolver` for that.

### Trust and size limits

Discovering skills over MCP means an external MCP server controls what skill content (including
instructions and supporting resources) reaches the agent. A compromised or
untrustworthy server could return adversarial content designed to manipulate the agent (indirect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@

<ItemGroup>
<PackageReference Include="Microsoft.Extensions.AI" />
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
<PackageReference Include="ModelContextProtocol" />
<PackageReference Include="ModelContextProtocol.Extensions.Tasks" />
</ItemGroup>
Expand Down
24 changes: 17 additions & 7 deletions dotnet/src/Microsoft.Agents.AI.Mcp/Skills/AgentMcpSkill.cs
Original file line number Diff line number Diff line change
Expand Up @@ -51,10 +51,11 @@ internal sealed partial class AgentMcpSkill : AgentSkill
private readonly string _skillRootUri;
private readonly IReadOnlyList<McpSkillResourceEntry> _resources;
private readonly long _maxResourceSizeBytes;
private readonly McpSkillsCatalog _skillsCatalog;
private readonly bool _hasIsolationKey;
private readonly TimeProvider _timeProvider;
private readonly Func<TimeSpan?, TimeSpan>? _ttlResolver;
private readonly McpCache<string, ResourceContents> _resourceCache;
private volatile bool _needsRefresh;

/// <summary>
/// Initializes a new instance of the <see cref="AgentMcpSkill"/> class.
Expand All @@ -63,22 +64,22 @@ internal sealed partial class AgentMcpSkill : AgentSkill
/// <param name="uri">The resource URI of the skill's <c>SKILL.md</c>.</param>
/// <param name="resources">The allowed resource URIs and their expected sizes and content hashes, including <c>SKILL.md</c>.</param>
/// <param name="client">The MCP client used to fetch <c>SKILL.md</c> and supporting resources.</param>
/// <param name="catalog">The catalog to notify when a content mismatch requires updated skill details.</param>
/// <param name="logger">Optional logger for validation diagnostics.</param>
/// <param name="maxResourceSizeBytes">The maximum allowed resource size in bytes.</param>
/// <param name="timeProvider">Optional clock for resource expiration.</param>
/// <param name="metadataLifetime">Optional lifetime for skill metadata. Defaults to no independent expiration; the catalog lifetime controls refreshes.</param>
/// <param name="ttlResolver">Optional callback to override the server's resource TTLs.</param>
/// <param name="hasIsolationKey">Whether the skill belongs to a cache partition key.</param>
/// <exception cref="ArgumentOutOfRangeException"><paramref name="maxResourceSizeBytes"/> is not positive.</exception>
public AgentMcpSkill(AgentSkillFrontmatter frontmatter, string uri, IReadOnlyList<McpSkillResourceEntry> resources, McpClient client, McpSkillsCatalog catalog, ILogger? logger = null, long maxResourceSizeBytes = DefaultMaxResourceSizeBytes, TimeProvider? timeProvider = null, McpCacheLifetime? metadataLifetime = null, Func<TimeSpan?, TimeSpan>? ttlResolver = null)
public AgentMcpSkill(AgentSkillFrontmatter frontmatter, string uri, IReadOnlyList<McpSkillResourceEntry> resources, McpClient client, ILogger? logger = null, long maxResourceSizeBytes = DefaultMaxResourceSizeBytes, TimeProvider? timeProvider = null, McpCacheLifetime? metadataLifetime = null, Func<TimeSpan?, TimeSpan>? ttlResolver = null, bool hasIsolationKey = false)
{
this.Frontmatter = Throw.IfNull(frontmatter);
this.Uri = Throw.IfNullOrWhitespace(uri);
this.MetadataLifetime = metadataLifetime ?? McpCacheLifetime.Infinite;
this._skillRootUri = this.Uri[..^"SKILL.md".Length];
this._resources = Throw.IfNull(resources);
this._client = Throw.IfNull(client);
this._skillsCatalog = Throw.IfNull(catalog);
this._hasIsolationKey = hasIsolationKey;
this._timeProvider = timeProvider ?? TimeProvider.System;
this._ttlResolver = ttlResolver;
this._resourceCache = new(this._timeProvider, StringComparer.Ordinal);
Expand All @@ -100,14 +101,20 @@ public AgentMcpSkill(AgentSkillFrontmatter frontmatter, string uri, IReadOnlyLis
/// </summary>
internal McpCacheLifetime MetadataLifetime { get; }

/// <summary>
/// Gets whether a resource mismatch requires updated skill metadata.
/// </summary>
internal bool NeedsRefresh => this._needsRefresh;

/// <inheritdoc/>
/// <remarks>
/// Returns the text of <c>SKILL.md</c>, using cached content until it expires.
/// Fetched content is checked against the expected size, content hash, name, and description.
/// A size or content hash mismatch returns previously verified content if available and queues a skill refresh.
/// </remarks>
/// <exception cref="InvalidOperationException">The <c>SKILL.md</c> resource is missing or invalid,
/// exceeds the size limit, has a different name or description, or fails the size or content hash check without cached content to return.</exception>
/// exceeds the size limit, has a different name or description, fails the size or content hash check without cached content to return,
/// or is private and no cache partition key is set.</exception>
public override async ValueTask<string> GetContentAsync(CancellationToken cancellationToken = default)
{
McpSkillResourceEntry? skillMdResource = this._resources.FirstOrDefault(resource => string.Equals(resource.Uri, this.Uri, StringComparison.Ordinal));
Expand Down Expand Up @@ -135,7 +142,8 @@ public override async ValueTask<string> GetContentAsync(CancellationToken cancel
/// A size or content hash mismatch returns previously verified content if available and queues a skill refresh.
/// </remarks>
/// <exception cref="InvalidOperationException">The resource response is invalid, exceeds the size limit,
/// or fails the size or content hash check without cached content to return.</exception>
/// fails the size or content hash check without cached content to return,
/// or is private and no cache partition key is set.</exception>
public override async ValueTask<AgentSkillResource?> GetResourceAsync(string name, CancellationToken cancellationToken = default)
{
Throw.IfNullOrWhitespace(name);
Expand Down Expand Up @@ -195,13 +203,15 @@ private async ValueTask<McpCacheEntry<ResourceContents>> FetchResourceContentAsy
// Validation time consumes the TTL; network request time does not.
long receivedTimestamp = this._timeProvider.GetTimestamp();

response.CacheScope = McpSkillsCatalog.ValidateAndNormalizeCacheScope(response.CacheScope, this._hasIsolationKey, this._logger, "resources/read");

ResourceContents content = this.GetValidatedResourceContent(resource, response);
if (!this.TryVerifyResourceIntegrity(resource, content, out string? mismatch))
{
cancellationToken.ThrowIfCancellationRequested();

// The resource may have changed; request a new manifest without changing this skill's held manifest.
this._skillsCatalog.QueueSkillRefresh(this);
this._needsRefresh = true;
LogResourceIntegrityMismatch(this._logger, resource.Uri, this.Uri, mismatch);
cancellationToken.ThrowIfCancellationRequested();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.Diagnostics.CodeAnalysis;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Shared.Diagnostics;
Expand Down Expand Up @@ -32,6 +34,22 @@ namespace Microsoft.Agents.AI;
/// <see cref="AgentSkillsProviderBuilder.DisableCaching"/>, so this source can check expiration on each call.
/// </para>
/// <para>
/// By default, all callers share one cache. If this source is shared by several users or tenants,
/// set <see cref="AgentMcpSkillsSourceOptions.CacheIsolationKeySelector"/> so each of them gets its own cache partition.
/// </para>
/// <para>
/// According to the MCP specification, each <c>skills/list</c>, <c>skills/get</c>, and <c>resources/read</c> response
/// must declare a <c>cacheScope</c>. Not all servers do, so the source handles it as follows:
/// <list type="bullet">
/// <item><description>Missing or <see langword="null"/>: treated as <c>public</c>.</description></item>
/// <item><description><c>public</c>: always accepted.</description></item>
/// <item><description><c>private</c>: accepted only when <see cref="AgentMcpSkillsSourceOptions.CacheIsolationKeySelector"/> is set;
/// otherwise, <see cref="InvalidOperationException"/> is thrown.</description></item>
/// </list>
/// Each response is checked on its own. For example, a skill from a public list still fails if its
/// <c>SKILL.md</c> is returned as private and no selector is set.
/// </para>
/// <para>
/// Listing a skill does not fetch its files. The returned skill object fetches <c>SKILL.md</c>
/// and supporting resources only when their content is requested. Only resources in its manifest are available.
/// Each resource response must contain one text or binary content block; <c>SKILL.md</c> must contain text.
Expand Down Expand Up @@ -93,12 +111,18 @@ public sealed partial class AgentMcpSkillsSource : AgentSkillsSource
private readonly McpClient _client;
private readonly ILogger _logger;
private readonly McpSkillsCatalog _catalog;
private readonly AgentMcpSkillsSourceOptions? _options;
private readonly TimeProvider _timeProvider;
private readonly MemoryCache _catalogs;
private readonly int? _maxCachedPartitions;
private readonly MemoryCacheEntryOptions? _catalogEntryOptions;
private bool _disposed;

/// <summary>
/// Initializes a new instance of the <see cref="AgentMcpSkillsSource"/> class.
/// </summary>
/// <param name="client">An MCP client connected to a server that supports <c>skills/list</c>. The caller retains ownership of the client and is responsible for disposing it.</param>
/// <param name="options">Optional TTL resolver and limits for listing pages, skill counts, and resource sizes.</param>
/// <param name="options">Optional cache partitioning and retention settings, TTL resolver, and limits for listing pages, skill counts, and resource sizes.</param>
/// <param name="loggerFactory">Optional factory for diagnostic logging.</param>
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
/// <exception cref="ArgumentOutOfRangeException">A configured limit is not positive.</exception>
Expand All @@ -112,7 +136,18 @@ internal AgentMcpSkillsSource(McpClient client, AgentMcpSkillsSourceOptions? opt
this._client = Throw.IfNull(client);
loggerFactory ??= NullLoggerFactory.Instance;
this._logger = loggerFactory.CreateLogger<AgentMcpSkillsSource>();
this._catalog = new McpSkillsCatalog(this._client, options, this._logger, Throw.IfNull(timeProvider));
this._timeProvider = Throw.IfNull(timeProvider);
this._options = options;

this._catalog = new McpSkillsCatalog(this._client, this._options, this._logger, this._timeProvider);

var cacheOptions = options?.ToMemoryCacheOptions() ?? new MemoryCacheOptions();
cacheOptions.Clock = new CatalogClock(this._timeProvider);

this._catalogs = new MemoryCache(cacheOptions);
this._maxCachedPartitions = options?.MaxCachedPartitions;

this._catalogEntryOptions = options?.ToMemoryCacheEntryOptions();
}

/// <inheritdoc/>
Expand All @@ -130,19 +165,65 @@ public override async Task<IList<AgentSkill>> GetSkillsAsync(AgentSkillsSourceCo
return [];
}

IList<AgentSkill> skills = await this._catalog.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
string? key = this._options?.CacheIsolationKeySelector is { } selector
? Throw.IfNullOrWhitespace(await selector(context, cancellationToken).ConfigureAwait(false))
: null;

cancellationToken.ThrowIfCancellationRequested();
ObjectDisposedException.ThrowIf(this._disposed, this);

McpSkillsCatalog catalog = key is null
? this._catalog
: this.GetOrCreateCatalog(key);

IList<AgentSkill> skills = await catalog.GetSkillsAsync(cancellationToken).ConfigureAwait(false);
LogSkillsLoadedTotal(this._logger, skills.Count);

return skills;
}

private McpSkillsCatalog GetOrCreateCatalog(string key)
{
if (this._catalogs.TryGetValue(key, out McpSkillsCatalog? catalog))
{
return catalog!;
}

// Serialize partition creation and compaction so a new partition has room in the cache.
lock (this._catalogs)
{
if (this._catalogs.TryGetValue(key, out catalog))
{
return catalog!;
}

int count = this._catalogs.Count;
if (this._maxCachedPartitions is > 0 && count >= this._maxCachedPartitions)
{
// Target 5% (5 of 100 entries) to avoid compacting for every new key.
// Since Compact rounds down, 1.0 / count ensures a target of at least one entry for small caches.
this._catalogs.Compact(Math.Max(0.05, 1.0 / count));
}

return this._catalogs.GetOrCreate(
key,
_ => new McpSkillsCatalog(this._client, this._options, this._logger, this._timeProvider, hasIsolationKey: true),
this._catalogEntryOptions)!;
}
}

private sealed class CatalogClock(TimeProvider timeProvider) : ISystemClock
{
public DateTimeOffset UtcNow => timeProvider.GetUtcNow();
}

/// <inheritdoc/>
protected override void Dispose(bool disposing)
{
if (disposing)
if (disposing && !this._disposed)
{
this._catalog.Dispose();
this._disposed = true;
this._catalogs.Dispose();
Comment thread
SergeyMenshykh marked this conversation as resolved.
Resolved
}

base.Dispose(disposing);
Expand Down
Loading
Loading