I run a few MCP servers in production on ASP.NET Core. On September 13, SEP-2640 merged and Skills became an official MCP extension. This is what I actually had to change, what I didn’t, and where I’m still not convinced.
The rivalry that was never real
For most of this summer, my reading list had a weird split personality.
Half the articles I saved were about MCP tools: typed operations, JSON schemas, tools/call, the whole "give the agent hands" story. The other half were about Agent Skills: a folder with a SKILL.md, some reference files, maybe a script, loaded on demand so the model knows how to do something. And a surprising number of those articles framed the two as competitors. "Do you still need MCP if you have Skills?" "Skills are the new MCP." I'll admit I half bought into it. I remember sketching a slide for my team titled "Tools vs Skills" with two columns, as if we were going to pick one.
That framing was always going to collapse, and in hindsight the reason is obvious. A tool answers what can I do. A resource answers what can I see. A skill answers in what order, under which conditions, and with which caveats should I do those things. They are three different layers. A skill with no tools is a well-written wiki page the model can’t act on. A tool with no skill is a sharp knife with no recipe, which is exactly what I kept seeing in production: the model had every operation it needed on my server and still called them in the wrong order, or skipped the one read that should have come first.
The real problem was never “which one wins.” It was that the procedure knowledge lived in the wrong place. It sat in READMEs, in prompt files copied between repos, in someone’s local ~/.claude/skills folder, and it drifted away from the server it described every time we shipped a new tool version. SEP-2640 fixes that specific problem, and not much more. Which is fine. That's the right size for a protocol change.
What SEP-2640 actually adds
The extension ID is io.modelcontextprotocol/skills. It's an extension, not a change to core MCP, and it's built on top of the Resources primitive you probably already implement. No new transport, no new content type. Just an index with integrity checks on top of files you could already serve.
Concretely, three things:
- skills/list : returns every skill the server exposes. Each entry carries the URI of its SKILL.md, the full YAML frontmatter serialized as JSON, and a manifest of every file in the skill with a SHA-256 digest and byte size. Paginated with the usual cursor / nextCursor.
- skills/get : fetches one entry by URI. Hosts use it to refresh an entry when verification fails, or to load a skill that wasn't in the listing. Servers are allowed to return partial listings, but they must answer skills/get for every skill they serve.
- resources/directory/read (optional): non-recursive directory listing, for skills whose instructions say things like "pick a template from templates/". You only get called for it if you declare directoryRead: true.
Reading the actual file content still goes through plain resources/read.
Here is what a skills/list response looks like on the wire:
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"resultType": "complete",
"skills": [
{
"uri": "skill://device-triage/SKILL.md",
"frontmatter": {
"name": "device-triage",
"description": "Diagnose why an IoT device stopped reporting. Use when a user asks why a device or SIM went offline."
},
"resources": [
{
"uri": "skill://device-triage/SKILL.md",
"digest": "sha256:3f1c0b8e6a0d4c2b9e7f5a1d8c3b2a4f6e9d0c1b7a5e3f2d4c6b8a0e1f3d5c7b",
"size": 812
},
{
"uri": "skill://device-triage/references/error-codes.md",
"digest": "sha256:a9e4d2c1b0f8e7d6c5b4a3f2e1d0c9b8a7f6e5d4c3b2a1f0e9d8c7b6a5f4e3d2",
"size": 2140
}
]
}
],
"ttlMs": 300000,
"cacheScope": "public"
}
}
A few details that matter more than they look:
- Every result needs resultType, ttlMs, and cacheScope. These come from the newer caching utilities in the 2026-07-28 protocol revision. I forgot them the first time and the MCP Inspector complained immediately.
- resources can be the string "dynamic" instead of a manifest, for skills whose content is generated per request. Hosts are allowed to refuse those. I'd avoid them unless you have a really good reason.
-
The URI convention is skill://
/SKILL.md , and the last path segment must match the skill's name. You can prefix with an org path (skill://acme/billing/refunds/SKILL.md). But a resource is not a skill because of its scheme. It's a skill because it appears in skills/list or resolves through skills/get. Identity is server plus URI, so two servers can both ship a code-review skill without stepping on each other.
And the capability declaration, which is how clients know to call any of this:
{
"capabilities": {
"resources": {},
"extensions": {
"io.modelcontextprotocol/skills": {
"directoryRead": false
}
}
}
}
Declaring the extension is a promise that you implement both skills/list and skills/get. You must also declare resources, because that's where the file bytes come from.
The part everyone skims: digests and security
The digest story is the most interesting design decision in the spec, and also the most misunderstood in the posts I’ve read since the merge.
The host keeps the entry it used to load a skill. Every file read gets checked against that entry’s byte size and SHA-256 digest, and the frontmatter in the actual SKILL.md is compared field by field against the frontmatter object from the listing. If anything doesn't match, the host throws the content away, calls skills/get to refresh, and has to ask for approval again. Persisted approvals are bound to the full set of URIs and digests, so adding one file to a skill revokes the user's earlier approval.
What digests are not is a signature. They prove the files you served match the manifest you served. They say nothing about whether you should be trusted. If your server is compromised, the attacker just recomputes the digests. The spec is explicit about this, and I think it’s the right call to not pretend otherwise.
The security rules that actually land on hosts:
- Loaded skill content must be tagged with its originating server.
- Reads are bound to that server. Cross-server reads need explicit per-call approval naming both servers.
- Skill content is untrusted input. No implicit local code execution, and things like allowed-tools grants need explicit per-skill approval.
- Name collisions must never be resolved by silently replacing one skill with another.
- A skill should stay under 512 files or 16 MiB.
As a server author, most of this isn’t your code. But it changes how you should write skills: assume the host will show your server name next to every instruction, and assume anything that asks for elevated permissions will trigger a prompt the user has to read.
Adding a Skill to an existing .NET MCP server
My starting point was a fairly typical ASP.NET Core MCP server built on ModelContextProtocol.AspNetCore. It exposes a handful of read-mostly tools against an IoT device fleet: get_device_status, get_network_sessions, get_recent_alerts, and one scary one, reset_device. The tools had been in production for months. The procedure for using them well lived in a Confluence page nobody read.
At the time of writing, the C# SDK does not ship first-class Skills support in a stable package. There is work in flight for a ModelContextProtocol.Extensions.Skills package, so check the repo before you hand-roll this. What the v2 SDK does give you is exactly the escape hatch you need: McpServerOptions.RequestHandlers, a list of McpServerRequestHandler objects that register raw JSON-RPC methods, and ServerCapabilities.Extensions for the declaration. Both are marked experimental (MCPEXP002), so you'll need to suppress that diagnostic.
Step 1: the skill on disk
I keep skills in the server repo, next to the tools they describe. That’s the whole point: when the tool changes, the procedure changes in the same PR.
src/DeviceDiagnostics.Mcp/
skills/
device-triage/
SKILL.md
references/
error-codes.md
Program.cs
SkillCatalog.cs
SkillsHandlers.cs
DeviceTools.cs
And the SKILL.md itself:
---
name: device-triage
description: Diagnose why an IoT device stopped reporting. Use when a user asks why a device or SIM went offline, or why a fleet segment has gaps in its data.
metadata:
owner: platform-team
revision: "4"
---
# Device triage
1. Always start with `get_device_status` for the device ID the user gave.
Do not guess an ID from a partial name.
2. If `lastSeen` is older than 24 hours, call `get_network_sessions`
with `hours: 48` before drawing any conclusion.
3. Only read `references/error-codes.md` if a session shows a
non-zero `detachCause`.
4. Report the most likely cause, the evidence for it, and one next step.
5. Never call `reset_device` unless the user explicitly asks for a reset
in this conversation. Explain what a reset does before calling it.
Step 5 is there because of an incident I’d rather not describe in detail. The short version: the model was being helpful. A skill can’t enforce that rule, only the tool’s authorization code can, but it dramatically reduced how often the model even proposed it.
Step 2: build the catalog and digests
This is the piece that has to be exactly right, because the host will verify every byte. The one gotcha that cost me an hour: the digest must be computed over the bytes you actually serve. Visual Studio on one teammate’s machine saved SKILL.md with a UTF-8 BOM. The digest was computed over the file including the BOM, but the SDK served the text as a string without it. Size mismatch, digest mismatch, rejected by the host. So the catalog strips the BOM once at load time and uses those bytes for everything.
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using YamlDotNet.Serialization;
namespace DeviceDiagnostics.Mcp;
public sealed record SkillFile(string Uri, string Text, string Digest, int Size, string MimeType);
public sealed record SkillEntry(string Uri, JsonObject Frontmatter, IReadOnlyList<SkillFile> Files)
{
public JsonObject ToJson() => new()
{
["uri"] = Uri,
["frontmatter"] = Frontmatter.DeepClone(),
["resources"] = new JsonArray(Files.Select(f => (JsonNode)new JsonObject
{
["uri"] = f.Uri,
["digest"] = f.Digest,
["size"] = f.Size
}).ToArray())
};
}
public sealed class SkillCatalog
{
private static readonly byte[] Utf8Bom = [0xEF, 0xBB, 0xBF];
private readonly Dictionary<string, SkillEntry> _byUri = new(StringComparer.Ordinal);
public IReadOnlyCollection<SkillEntry> Entries => _byUri.Values;
public IEnumerable<SkillFile> AllFiles => _byUri.Values.SelectMany(e => e.Files);
public bool TryGet(string uri, out SkillEntry entry) => _byUri.TryGetValue(uri, out entry!);
public static SkillCatalog Load(string skillsRoot)
{
var catalog = new SkillCatalog();
foreach (var skillDir in Directory.GetDirectories(skillsRoot))
{
var skillName = Path.GetFileName(skillDir);
var files = new List<SkillFile>();
foreach (var path in Directory.GetFiles(skillDir, "*", SearchOption.AllDirectories)
.OrderBy(p => p, StringComparer.Ordinal))
{
var bytes = File.ReadAllBytes(path);
if (bytes.AsSpan().StartsWith(Utf8Bom))
bytes = bytes[Utf8Bom.Length..];
var relative = Path.GetRelativePath(skillDir, path).Replace('\\', '/');
var uri = $"skill://{skillName}/{relative}";
var digest = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
var mime = path.EndsWith(".md", StringComparison.OrdinalIgnoreCase) ? "text/markdown" : "text/plain";
files.Add(new SkillFile(uri, Encoding.UTF8.GetString(bytes), digest, bytes.Length, mime));
}
var skillMd = files.Single(f => f.Uri.EndsWith("/SKILL.md", StringComparison.Ordinal));
var frontmatter = ParseFrontmatter(skillMd.Text);
if (frontmatter["name"]?.GetValue<string>() != skillName)
throw new InvalidOperationException($"Skill folder '{skillName}' does not match its frontmatter name.");
if (files.Count > 512 || files.Sum(f => (long)f.Size) > 16 * 1024 * 1024)
throw new InvalidOperationException($"Skill '{skillName}' exceeds the 512 file / 16 MiB limit.");
catalog._byUri[skillMd.Uri] = new SkillEntry(skillMd.Uri, frontmatter, files);
}
return catalog;
}
private static JsonObject ParseFrontmatter(string markdown)
{
var lines = markdown.Split('\n');
if (lines.Length == 0 || lines[0].TrimEnd() != "---")
throw new InvalidOperationException("SKILL.md must start with YAML frontmatter.");
var end = Array.FindIndex(lines, 1, l => l.TrimEnd() == "---");
var yaml = string.Join('\n', lines[1..end]);
// Round-trip through YamlDotNet's JSON-compatible emitter so nested maps survive.
var raw = new DeserializerBuilder().Build().Deserialize<object>(yaml);
var jsonText = new SerializerBuilder().JsonCompatible().Build().Serialize(raw);
var node = JsonNode.Parse(jsonText)!.AsObject();
if (node["name"] is null || node["description"] is null)
throw new InvalidOperationException("Frontmatter requires 'name' and 'description'.");
return node;
}
}
The frontmatter round trip looks convoluted, and it is. The reason: the spec says the frontmatter object must contain every field, unchanged, and hosts compare it field by field with their own parse of the YAML. YamlDotNet's untyped deserializer hands you nested Dictionary values that System.Text.Json can't serialize directly, and the JSON-compatible serializer is the least bad way I found to get a faithful JSON shape. Quote anything that looks like a number or boolean in your frontmatter (revision: "4") and you'll avoid a whole category of "why does the host think this changed" bugs.
Failing loudly at startup is deliberate. I’d much rather the pod crash-loops in staging than serve a manifest the host will reject in production.
Step 3: the skills/list and skills/get handlers
#pragma warning disable MCPEXP002
using System.Text.Json.Nodes;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
namespace DeviceDiagnostics.Mcp;
public static class SkillsHandlers
{
private const int TtlMs = 300_000;
public static IList<McpServerRequestHandler> Create(SkillCatalog catalog) =>
[
new McpServerRequestHandler
{
Method = "skills/list",
Handler = (request, ct) =>
{
JsonNode result = new JsonObject
{
["resultType"] = "complete",
["skills"] = new JsonArray(catalog.Entries.Select(e => (JsonNode)e.ToJson()).ToArray()),
["ttlMs"] = TtlMs,
["cacheScope"] = "public"
};
return ValueTask.FromResult<JsonNode?>(result);
}
},
new McpServerRequestHandler
{
Method = "skills/get",
Handler = (request, ct) =>
{
var uri = request.Params?["uri"]?.GetValue<string>();
if (uri is null || !catalog.TryGet(uri, out var entry))
throw new McpProtocolException($"Unknown skill: {uri}", McpErrorCode.InvalidParams);
JsonNode result = new JsonObject
{
["resultType"] = "complete",
["skill"] = entry.ToJson(),
["ttlMs"] = TtlMs,
["cacheScope"] = "public"
};
return ValueTask.FromResult<JsonNode?>(result);
}
}
];
}
Two decisions worth calling out. First, an unknown URI is -32602 (Invalid params), which is what the spec asks for, so the host stops loading instead of retrying. Second, cacheScope is "public" because every caller sees the same skills. If you filter skills per user or per tenant, that has to become private, otherwise a shared cache between host and server will hand one tenant's catalog to another. I have one server where that applies and I nearly missed it.
I don’t paginate. With a small catalog, a single page is fine, and the spec says entries are atomic anyway: you can’t split one skill’s manifest across pages.
Step 4: wire it into Program.cs
#pragma warning disable MCPEXP002
using DeviceDiagnostics.Mcp;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
var builder = WebApplication.CreateBuilder(args);
var catalog = SkillCatalog.Load(Path.Combine(builder.Environment.ContentRootPath, "skills"));
builder.Services.AddSingleton(catalog);
builder.Services.AddSingleton<DeviceStatusService>();
builder.Services
.AddMcpServer(options =>
{
options.ServerInfo = new() { Name = "device-diagnostics", Version = "2.5.0" };
options.Capabilities ??= new ServerCapabilities();
options.Capabilities.Resources ??= new ResourcesCapability();
options.Capabilities.Extensions = new Dictionary<string, object>
{
["io.modelcontextprotocol/skills"] = new Dictionary<string, object>()
};
options.RequestHandlers = SkillsHandlers.Create(catalog);
// Serve the exact bytes the manifest was computed from.
options.ResourceCollection ??= new();
foreach (var file in catalog.AllFiles)
{
var captured = file;
options.ResourceCollection.Add(McpServerResource.Create(
() => captured.Text,
new McpServerResourceCreateOptions
{
UriTemplate = captured.Uri,
Name = captured.Uri.Split('/').Last(),
MimeType = captured.MimeType
}));
}
})
.WithHttpTransport()
.WithTools<DeviceTools>();
var app = builder.Build();
app.MapMcp("/mcp");
app.Run();
The empty dictionary under the extension key means “I support Skills, without directory reads.” My existing DeviceTools class didn't change at all. That was the moment the "rivals" framing fully died for me: the tools are the same tools, the skill just tells the model how to sequence them.
The .csproj needs these packages:
<ItemGroup>
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="*" />
<PackageReference Include="YamlDotNet" Version="16.*" />
</ItemGroup>
Pin a real version before you ship. I use * here only because the SDK has been moving fast enough that any number I type will be stale by the time you read this.
Testing it locally without paying for a single token
I wanted to check that a real client could list the skill, verify it, and that a model would actually follow it, without burning API budget on every iteration. Ollama in Docker plus a tiny console client does the job.
docker run -d --name ollama -p 11434:11434 -v ollama:/root/.ollama ollama/ollama
docker exec ollama ollama pull llama3.1:8b
dotnet run --project src/DeviceDiagnostics.Mcp --urls http://localhost:5080
Then a console app that behaves like a minimal host: list, verify digest and size, load, prompt.
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json.Nodes;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
await using var mcp = await McpClient.CreateAsync(
new HttpClientTransport(new() { Endpoint = new Uri("http://localhost:5080/mcp") }));
var list = await mcp.SendRequestAsync(
new JsonRpcRequest { Method = "skills/list", Params = new JsonObject() }, CancellationToken.None);
var skill = list.Result!["skills"]![0]!;
var skillUri = skill["uri"]!.GetValue<string>();
var expected = skill["resources"]!.AsArray().First(r => r!["uri"]!.GetValue<string>() == skillUri)!;
var read = await mcp.ReadResourceAsync(skillUri);
var text = ((TextResourceContents)read.Contents[0]).Text;
var bytes = Encoding.UTF8.GetBytes(text);
var digest = "sha256:" + Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant();
if (digest != expected["digest"]!.GetValue<string>() || bytes.Length != expected["size"]!.GetValue<int>())
throw new InvalidOperationException("Skill failed verification. A real host would refuse to load it.");
Console.WriteLine($"Verified {skillUri} ({bytes.Length} bytes)");
using var http = new HttpClient { BaseAddress = new Uri("http://localhost:11434") };
var response = await http.PostAsJsonAsync("/api/chat", new
{
model = "llama3.1:8b",
stream = false,
messages = new object[]
{
new { role = "system", content = $"[skill from server: device-diagnostics]\n{text}" },
new { role = "user", content = "Device 8842 hasn't sent data since yesterday. Can you just reset it?" }
}
});
var chat = await response.Content.ReadFromJsonAsync<JsonObject>();
Console.WriteLine(chat!["message"]!["content"]);
This is not a full host. It doesn’t wire the tools into the model loop or handle approvals. It’s a harness for two questions: does my manifest verify, and does the procedure actually change model behavior? For the second one, the reset prompt above is my favorite test. With the skill loaded, the 8B model consistently asks to check status first and explains what a reset does. Without it, it cheerfully agrees to reset.
Specialist agents or distributed skills? Microsoft measured it
The most useful thing I read after the merge was Tommaso Stocchi’s post on the Microsoft Agent Framework blog, because it ran the comparison I’d been arguing about in the abstract.
His ski resort demo has an advisor that needs weather, safety, coaching, and lift traffic information. Version one uses specialist agents over A2A: each specialist has its own model loop, its own instructions, its own tools, and the advisor calls it like a function. Version two keeps the same services running separately but turns each specialist into a distributed skill: a SKILL.md for the procedure plus typed MCP tools, with the reasoning moved into the advisor. The four MCP providers in that version are .NET, which made it very easy for me to map onto my own setup.
He sent the same prompt through both paths three times each. Here’s the summary I pulled from his published numbers:
+---------------------------+--------------------+--------------------+
| Metric (3 runs each) | A2A specialists | Skills over MCP |
+---------------------------+--------------------+--------------------+
| Mean elapsed time | 15.48 s | 6.35 s |
| Total tokens (all 3 runs) | 11,134 | 13,533 (~22% more) |
| Model calls per run | 6, 6, 7 | 3, 3, 3 |
| Avg output tokens per run | ~598 | ~171 |
| Cached input reported | unknown (partial) | 6,144 |
+---------------------------+--------------------+--------------------+
Faster, fewer model calls, but more tokens. That combination confused me for a minute until I looked at why. Every specialist in the A2A path works with a small private context: its own instructions and its own tool results. In the skills path, the advisor accumulates everything. The skill instructions, the tool schemas for the whole provider group, and the results all pile into one growing context. His first skills run had input counts of 866, 1,593, then 1,882 across its three calls. Fewer round trips, heavier round trips.
The author is careful about what this proves, and I want to be too. It’s three pairs. Processes were warm, cache hits varied, the orchestrators were in different languages, and the A2A path sometimes did more work (it called two lift-traffic operations where the skills path called one, and in one run it pulled in the ski coach). Token counts are not dollars either, since cached input is priced differently. And there’s an accounting trap he points out that I’ve fallen into myself: if you only look at the top-level API usage for the A2A advisor, you miss more than half the tokens, because the specialists’ spend doesn’t show up there.
So what does this mean for an actual .NET service? Here’s how I’m deciding now:
- Reach for a skill when the specialist’s “intelligence” is really just a procedure. If your specialist agent’s system prompt is essentially “call A, then B if X, format like this,” you’re paying for an extra model loop to read instructions aloud. Move the instructions to the caller. Latency drops, and in my experience debugging gets much easier, because there’s one reasoning trace instead of nested ones.
- Keep a real agent when the specialist needs its own model (a cheaper one, a fine-tuned one), private context the caller shouldn’t see, a long-running workflow, or an independent release cycle owned by another team. Putting that behind MCP without changing its internals is just a transport change.
- Watch the context budget if you have many skills per provider. His demo defers exposing a provider’s tools until the model loads the matching skill. With four skills and twelve tools that’s optional. With forty tools it probably isn’t.
My device server fits the first bucket perfectly. The “triage agent” we’d prototyped was 90% procedure and 10% formatting. It never needed its own model.
One important footnote if you copied that demo: its skill discovery uses a skill://index.json resource from an earlier draft of SEP-2640, not the final skills/list and skills/get methods. It was written before the merge. Treat it as a pattern reference, not a protocol reference.
Backward compatibility: what breaks, and what doesn’t
The good news first. Almost nothing breaks, because the spec is designed so that clients only call skills/list and skills/get after seeing your extension declaration. An old client ignores the extensions key it doesn't understand and carries on with tools and resources exactly as before.
But “nothing breaks” isn’t the same as “nothing changes.” Here’s what I ran into:
+-------------------------------+-------------------------------------------+
| Client type | What it sees from your server |
+-------------------------------+-------------------------------------------+
| Old client, no Skills support | Tools as before. SKILL.md and reference |
| | files may show up as plain resources in |
| | resources/list, with no verification and |
| | no origin tagging. |
+-------------------------------+-------------------------------------------+
| Host with pre-merge draft | Looks for skill://index.json, finds |
| support (e.g. early demos) | nothing. Skills silently absent. |
+-------------------------------+-------------------------------------------+
| Host with SEP-2640 support | Full list, verify, approve, load flow. |
+-------------------------------+-------------------------------------------+
| Filesystem-only skill users | Unaffected, but now maintaining a second |
| (local .claude/skills etc.) | copy that will drift from the server. |
+-------------------------------+-------------------------------------------+
The first row is the one to think about. If you register skill files in your ResourceCollection like I did, older clients will list them as ordinary resources. For my servers that's harmless noise. If your skills contain anything you'd only want loaded with an approval prompt, you might prefer to serve the skill files through a ReadResourceHandler so they're readable by URI but not listed. That's a real tradeoff and I don't think there's one right answer.
The migration path I’m using for the servers we have in production:
- Ship the extension behind a config flag. Declaring the capability is a promise, so the flag controls both the declaration and the handlers together. Never one without the other.
- Start with one bounded, read-oriented skill. Mine was triage. Nothing that can mutate state.
- Keep the old path alive. Whatever you had before (a prompt file, a specialist agent, a README the team pastes into chat) stays for at least one release while you compare routing and results.
- Add a startup test that recomputes every digest from the files in the built container image. Most of the verification failures I’ve seen came from build pipelines touching line endings, not from code.
- Bump skill content like you bump API contracts. A changed digest revokes user approvals, so a typo fix in SKILL.md has a user-visible cost. Batch your wording changes.
- Only then delete the old path.
My honest take: simplification, or the same complexity somewhere else?
I’ve gone back and forth on this, so let me split the answer.
What genuinely got simpler: distribution and drift. Before this, the procedure for using my tools and the tools themselves shipped through completely different channels. Now they ship in the same container, from the same PR, with the same version number. When someone on the team updates a skill, every connected host picks it up on the next listing. That alone removes a class of bugs I’ve spent real hours on, the “the model is following last month’s instructions” class. The integrity model is also honest about its limits, which I appreciate more than I expected to.
What just moved: almost everything else. Deciding when a skill should load is still the host’s problem and still depends on how good your description is, which is prompt engineering wearing a YAML hat. Enforcing behavior still has to happen in tool code, because a skill that says "never reset without confirmation" is a suggestion, not an authorization check. And the token math from the Microsoft demo shows the cost didn't vanish either. It moved from "many small model loops" to "one big context," which is a better trade for latency and a worse one for context-window pressure.
There’s also a new kind of work that didn’t exist before: manifest hygiene. Digests, BOMs, frontmatter round-trips, approval revocation when you fix a typo. None of it is hard. All of it is the sort of thing that fails at 2 AM because a build agent changed its line-ending setting.
JIN had a line of thinking in a recent piece that stuck with me: a skill isn’t the markdown file, it’s the whole lifecycle around it, including testing whether it fires when it should and noticing when it goes stale against the API it describes. SEP-2640 standardizes the transport part of that lifecycle. It doesn’t write your evals. If you weren’t testing your prompts before, you now have versioned, digested, approved prompts that are still untested.
So my verdict: it’s a net simplification for teams who already had MCP servers and were maintaining skills or prompt files somewhere else. For those teams, including mine, this collapses two moving parts into one. For teams who were fine with tools alone, it’s an optional layer that’s worth adding only when you can point at a specific failure it fixes. For me that failure was a model resetting a device. For you it might be nothing yet, and that’s a perfectly good reason to wait for the official C# extension package instead of hand-rolling the handlers above.
Either way, stop treating Tools and Skills as rivals. They were never competing for the same job.
Further reading
- AI Engineering (orig. winkrun), “MCP Protocol Just Got Another Upgrade: Skills Is Now Officially Merged”: https://ai-engineering-trend.medium.com/mcp-protocol-just-got-another-upgrade-skills-is-now-officially-merged-39cde7ce9496
- Tommaso Stocchi, Microsoft Agent Framework blog, “From Specialist Agents to Distributed Skills over MCP”: https://devblogs.microsoft.com/agent-framework/from-specialist-agents-to-distributed-skills-over-mcp/
- W Shamim, “Architecting Agentic Skills: The Definitive Guide to Building Production-Grade AI Capabilities”: https://medium.com/@Shamimw/architecting-agentic-skills-the-definitive-guide-to-building-production-grade-ai-capabilities-1952a8ee135f
- JIN, “Agent Skill Is Not a SKILL.md”: https://medium.com/jin-system-architect/agent-skill-is-not-a-skill-md-a38f10b6f968
- MCP Skills extension overview: https://modelcontextprotocol.io/extensions/skills/overview
Tags: mcp, dotnet, csharp, aiagents, agentskills, modelcontextprotocol, aspnetcore, llm, softwarearchitecture
Strongest 5 for Medium: MCP, .NET, AI Agents, Model Context Protocol, Software Architecture
Top comments (0)