ลองใช้ Microsoft Agent Framework – Agent Skills

สำหรับวันนี้ Blog ชุด Microsoft Agent Framework และเรื่องผมไปพูดในงาน Build.AI 2026 ที่ผ่านมาครับ โดย Feature นี้มันมีมาพักใหญ่ๆ แล้วครับ

For English Version: Using Skills in Microsoft Agent Framework – C#

ผมเล่นตั้งแต่ช่วยหยุดสงกรานต์ ตอนนี้อยู่มีเสียงในหัว พวก Claude / Herme มันเอา Skill.md เข้ามาใส่ แล้วที่นี้ตัว Microsoft Agent Framework มันใช่ได้ไหม ปรากฏว่ามันมีครับ เลยเป็นที่มาของลองเล่นกับน้อง Cat / Claude 56555 หัวข้อใน Blog จะมีประมาณนี้ครับ

Recap Microsoft Agent Framework สั้นๆ

Microsoft Agent Framework(MAF) เป็น NuGet Lib ที่เข้ามาช่วยให้การสร้าง AI Agent บน Stack ของ dotnet / python และ Golang (preview) ง่ายและสะดวกขึ้น โดยรองรับทั้งการ Chat แบบทั้งไป หรือ ทำ Workflow แม้แต่การทำ Harness Agent ที่มีการจัดการเรื่อง Memory / Tools Approval สามารถทำได้

สำหรับใน Blog นี้จะเน้น Skill เน้นๆ

Skill คือ อะไร ?

ชุดความสามารถบางเรื่องที่สนใจ ถูกสกัดออกมาเป็น Prompt เล็ก รวมถึง Script เพื่อมาช่วย เพื่อให้ LLM เรียกใช้ ส่วนใหญ่เป็น Python ผมลองลอง C# Script แล้วมันยังมีข้อจำกัดเยอะอยู่

ตัวอย่าง expense-report แสดงตัวอย่างของ Skill ตามนี้

skills/savings-calculator/
├── SKILL.md                             ← คำสั่ง/คู่มือให้ agent อ่าน
├── references/
│   └── formula.md                    ← อธิบายสูตร Script เผื่อ User ถาม
└── scripts/
    ├── project-savings.py           ← Script สำหรับคำนวณยอดเงินฝาก
    └── project-debt-payoff.py    ← Script สำหรับคำนวณหนี้ ผ่อนเท่าไหร่
---
name: savings-calculator
description: Use this whenever a user needs an exact number for a savings plan — the projected balance a goal will reach by its target date, how many months a contribution plan needs to hit a target amount, or how many months it takes to pay off a debt at a given payment and interest rate. Always prefer this over estimating the math yourself.
license: MIT
compatibility: "Requires a Python 3 runtime available to the host process (python3 on PATH)."
metadata:
  author: pingkunga-finance
  version: "1.0"
---
INSTRUCTION

หรือ สามารถเฉพาะทางอย่าง Coding ทำได้นะ อันนี้อยู่ใน Sample ของผมด้วย เป็นสำหรับ Code Review มันหา Symbols  / API ก่อนให้ AI สรุป PR

Skill vs Workflow

Viewใช้ Skill เมื่อใช้ Workflow เมื่อ
Controlต้องการให้ AI ตัดสินใจเอง ยืดหยุ่น/creativeต้องการ execution path ที่ deterministic แน่นอน
Resiliencefail แล้ว retry ใหม่ทั้ง turn ได้มี checkpoint, resume จาก step ล่าสุดได้
เมื่อ re-run ทั้งหมดต้นทุนสูง
Side effectsoperation idempotent/low-risk
ทำซ้ำกี่ครั้งผลลัพธ์เหมือนเดิม
มี side effect จริง (ส่งอีเมล, ตัดเงิน) ที่ retry ซ้ำไม่ได้
Complexityงาน single-domain, agent เดียวจัดการไหวmulti-step business process, หลาย agent/human approval/ระบบภายนอก

การเลือก Skill (Progressive disclosure)

จาก SKILL.md มันจะมีส่วนที่บอกว่า Skill นั้นทำอะไร โดยที่ตัว MAF-Agent Skills มีชั้นตอนเลือกใช้ 4 ขั้นตอน ดังนี้

  • Advertise (~100 tokens per skill) - ดูจาก Skill names + descriptions และระบุใน system prompt ว่าเรามีความสามารถอะไรบ้าง
  • Load (< 5000 tokens recommended) - ถ้า Skill ตรงเงื่อนไขที่ LLM ต้องการตัว MAF จะเรียก SKILL.md อ่านทั้งหมดเลย แนะนำว่าไม่ครรเกิน 5000 tokens
  • Read resources - ถ้า LLM เลือก Skill นั้น แล้ว LLM เห็นว่าต้องใช้ resource ที่เกี่ยวข้อง MAF เรียก read_skill_resource ดึงขึ้นมาอ้างอิง
  • Run scripts -ถ้า LLM เลือก Skill นั้น แล้ว LLM เห็นว่าต้องใช้ Script ตัว MAF เรียก run_skill_script ขึ้นมาทำงานและอ่านผลลัพธ์ที่ได้ส่งกลับไปให้ตัว Agent

รู้จักกับ Agent Skills - Microsoft Agent Framework

ใน MAF มี Skills 4 แบบ File-based skills / Class-based skills / Code-defined skills และ MCP-based skills

- File-based skills

สำหรับอันนี้ เราเตรียม Skill.md ตาม Structure ของ Agent Skills โดยใน MAF มีส่วนของ Coding ที่ต้องสนใจ ดังนี้

  • AgentFileSkillsSourceOptions - บอกวิธีการดึง Skill และรูปแบบไฟล์ที่ใช้ได้งาน
  • จากนั้น AgentSkillsProvider - บอกให้ดึง Skill ขึ้นมาและ และ ถ้ามีพวก Script เราให้ Class ไหนการ Run ส่วนของ scriptRunner / UseFileScriptRunner ซึ่งถ้าเราอ่าน Doc MAF มันให้ใช้ SubprocessScriptRunner แต่มันไม่ได้ Build-In มานะ อันนี้ ผม Copy มาใช้งานเหมือนกัน
var fileOptions = new AgentFileSkillsSourceOptions
{
    AllowedResourceExtensions = [".md", ".txt"],
    AllowedScriptExtensions = [".py"],
    SearchDepth = 3, // Search up to 3 levels deep (default is 2)
    ResourceFilter = context => context.RelativeFilePath.StartsWith("references/"),
    ScriptFilter = context => context.RelativeFilePath.StartsWith("scripts/")
                           || context.RelativeFilePath.StartsWith("tools/"),
};

// Via constructor
var skillsProvider = new AgentSkillsProvider(
    Path.Combine(AppContext.BaseDirectory, "skills"),
    fileOptions: fileOptions, scriptRunner: SubprocessScriptRunner.RunAsync);

// Via builder
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills"), options: fileOptions)
    .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
    .Build();
- Class-based skills

อันนี้ถ้าเรามี Class ที่อยากให้ AI เรียกใช้ เราใส่ Attribute name, description, instructions, resources, scripts -เพื่อเปลี่ยนให้เป็น Skill ได้ทั้งที หรือ ทำเป็น Driver เพื่อไปเรียก Service / Business Logic ที่เกี่ยวข้องได้ โดยที่ตัว MAF เตรียม AgentClassSkill ให้ Extend มาแล้ว และ การแชร์ Skill ผ่าน NuGet Package ด้วย คนใช้เอา package ไป Reference ได้เลย

จาก Code ข้างล่างเป็นตัวอย่าง Skill การอ่าน Repo ใน Gitea เอามาเป็นตัวอย่างในอธิบาย AgentClassSkill ดังนี้

  • AgentSkillFrontmatter - มัน คือ Skill Name / Desc บอกว่ามันทำอะไรได้
  • Instructions - อันเป็น Prompt เตรียมไว้ อันนี้บอก ถ้ามันยาวมากๆแบ่งลง AgentSkillResource
  • AgentSkillResource - ให้ข้อมูลเพิ่มเติมเกี่ยวกับ Tools หรือเป็นตาราง Lookup ก็ได้
  • AgentSkillScript- Method ให้ Agent ไปเรียกรัน
using System.ComponentModel;
using System.Text.Json;
using Microsoft.Agents.AI;
using Microsoft.Extensions.Logging;

namespace GiteaAiSummarizer.Services;

public sealed class GiteaSkill : AgentClassSkill<GiteaSkill>
{
    private readonly GiteaApiClient _gitea;
    private readonly ILogger<GiteaSkill> _logger;

    public GiteaSkill(GiteaApiClient gitea, ILogger<GiteaSkill> logger)
    {
        _gitea = gitea;
        _logger = logger;
    }

    public override AgentSkillFrontmatter Frontmatter { get; } =
        new(
            "gitea-tools",
            "Provides access to Gitea repository information including issues, comments, and file history."
        );

    protected override string Instructions =>
        """
    Use this skill to gather additional context from the Gitea repository.

    1. Read `gitea-tool-catalog` before selecting a Gitea tool.
    2. Follow its selection rules and limitations.
    3. Only report conclusions supported by the diff or tool results.
    """;

    [AgentSkillScript("get_issue")]
    [Description("Fetches detailed information about a Gitea issue or pull request by its number.")]
    public async Task<string> GetIssueAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The issue or pull request number.")] int number,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Fetching issue details for {Owner}/{Repo}#{Number}",
            owner,
            repo,
            number
        );
        // Gitea API treats PRs and Issues similarly for metadata
        var url = $"{_gitea.BaseUrl}/api/v1/repos/{owner}/{repo}/issues/{number}";
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        _gitea.AddHeaders(request);

        var response = await _gitea.HttpClient.SendAsync(request, ct);
        if (!response.IsSuccessStatusCode)
            return $"Error: {response.StatusCode}";

        var content = await response.Content.ReadAsStringAsync(ct);
        return content;
    }

    [AgentSkillScript("get_issue_comments")]
    [Description("Fetches comments for a specific Gitea issue or pull request.")]
    public async Task<string> GetIssueCommentsAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The issue or pull request number.")] int number,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Fetching comments for {Owner}/{Repo}#{Number}",
            owner,
            repo,
            number
        );
        var comments = await _gitea.GetIssueCommentsAsync(owner, repo, number, ct);
        return JsonSerializer.Serialize(comments);
    }

    [AgentSkillScript("search_code")]
    [Description("Searches for a keyword or symbol within the repository code.")]
    public async Task<string> SearchCodeAsync(
        [Description("The owner of the repository.")] string owner,
        [Description("The name of the repository.")] string repo,
        [Description("The keyword or symbol to search for.")] string keyword,
        CancellationToken ct = default
    )
    {
        _logger.LogInformation(
            "Skill Call: Searching code for '{Keyword}' in {Owner}/{Repo}",
            keyword,
            owner,
            repo
        );
        var url =
            $"{_gitea.BaseUrl}/api/v1/repos/{owner}/{repo}/search?q={Uri.EscapeDataString(keyword)}&limit=10";
        using var request = new HttpRequestMessage(HttpMethod.Get, url);
        _gitea.AddHeaders(request);

        var response = await _gitea.HttpClient.SendAsync(request, ct);
        if (!response.IsSuccessStatusCode)
            return $"Error: {response.StatusCode}";

        var content = await response.Content.ReadAsStringAsync(ct);
        return content;
    }

    [AgentSkillResource("gitea-tool-catalog")]
    [Description(
        "Lookup table for selecting the appropriate Gitea tool during pull request analysis."
    )]
    public string GiteaToolCatalog =>
        """
    # Gitea Tool Catalog

    | Need | Tool | Required inputs | Limitation |
    |---|---|---|---|
    | Read an issue or PR's metadata | `get_issue` | owner, repo, number | Returns raw Gitea JSON |
    | Understand issue or PR discussion | `get_issue_comments` | owner, repo, number | Returns raw JSON comments |
    | Find references to a changed symbol | `search_code` | owner, repo, keyword | Maximum 10 matches; no match does not prove unused |

    ## Selection rules
    - PR text or diff refers to `#<number>`: use `get_issue`.
    - Acceptance criteria or prior decisions may be in a discussion: use `get_issue_comments`.
    - `impact_graph` identifies a changed symbol: use `search_code` once per unique symbol.
    - Treat empty or incomplete search results as inconclusive.
    """;
}

จริงๆ ถ้าใครเคยใช้ Semantic Kernel มาก่อน ผมว่า Class-based skills มันคล้ายกับพวก KernelFunction อยู่นะ

- Code-defined skills

ส่วนอันนี้ ถ้ามองมันคล้ายๆกับ Class เลยนะ แต่มันมีจุดต่างอยู่ ดังนี้ สร้าง skill แบบ dynamic ณ runtime อาทิ เช่น

  • personalize ต่อ user session
  • อ่านค่าจาก env/DB สดๆ หรือ ฝัง logic เฉพาะจุดที่เรียกใช้ ไม่ใช้ Static File
  • ต้องการใช้ค่าจาก Local Variables (close over call-site state)
  • อีกมุมนึงของผม เรามี Method Function เดิมอยู่แล้ว การเอา AgentInlineSkill มาครอบช่วยไม่ต้องรื่อเยอะ
var skill = new AgentInlineSkill(
            name: $"receipt-ocr-{receiptId:N}",
            description: "Extract vendor, amount, and date from this specific uploaded receipt image and record it as a transaction.",
            instructions: "Call extract_receipt to run OCR on this receipt and create the resulting transaction from it. " +
                          "To check this receipt's current status without re-running OCR, read the receipt_status resource instead.",
            license: null,
            compatibility: null,
            allowedTools: null,
            metadata: null,
            serializerOptions: null,
            argumentMarshaler: null);

        skill.AddScript(
            "extract_receipt",
            async Task<string> () => await ExtractAsync(chatClient, scopeFactory, userId, receiptId, supportsVision),
            "Runs OCR on the uploaded receipt image and creates a Transaction from the extracted fields. Takes no arguments — the receipt is fixed at skill-creation time.",
            null);

        // ChatSessionService.cs: DisableReadSkillResourceApproval = true, unconditional, 
        // what's the status of this receipt"
        skill.AddResource(
            "receipt_status",
            async Task<string> () => await DescribeStatusAsync(scopeFactory, userId, receiptId),
            "Current status of this receipt (pending/succeeded/failed/manual) and any already-extracted fields, " +
            "without re-running OCR. Read this to answer status questions instead of calling extract_receipt again.",
            null);

ที่นี้ในส่วน ExtractAsync / DescribeStatusAsync เราใส่ Logic ของตัวเองได้เลย

private static async Task<string> DescribeStatusAsync(IServiceScopeFactory scopeFactory, Guid userId, Guid receiptId)
{
	using var scope = scopeFactory.CreateScope();
	await using var db = CreateDbContext(scope.ServiceProvider, userId);
	
	var receipt = await db.Receipts.Include(r => r.ExtractedCategory).FirstOrDefaultAsync(r => r.Id == receiptId);
	// Logic for Check Receipts Status
      
	return $"Status: {receipt.OcrStatus}. {fields}. Linked transaction: {receipt.ResultingTransactionId}.";
}

private static async Task<string> ExtractAsync(IChatClient chatClient, IServiceScopeFactory scopeFactory, Guid userId, Guid receiptId, bool supportsVision)
{
	// Graceful degradation
	if (!supportsVision)
	{
		return "This AI provider doesn't support image input — please enter the receipt's vendor, " +
                   "amount, and date manually instead.";
	}
	
	// Check Same Receipt
	....
	
	// Call LLM to OCR Receipt
	string rawResponse;
	try
	{
		var response = await chatClient.GetResponseAsync(
		[
			new ChatMessage(ChatRole.User,
			[
				new TextContent(ExtractionPrompt),
				new DataContent(receipt.ImageBytes, receipt.ContentType),
			]),
		]);
		rawResponse = response.Text;
	}
	catch (Exception ex)
	{
		receipt.OcrStatus = ReceiptOcrStatus.Failed;
		receipt.OcrRawResponse = $"Error calling the AI provider: {ex.Message}";
		await db.SaveChangesAsync();
		return "Error: the AI provider call failed. The receipt is marked failed — try manual entry instead.";
	}

	// Extract Data 
	
	// Save in DB
	
    return $"Extracted vendor={extracted.Vendor ?? "(unknown)"}, amount={amount:C}, " +
               $"date={transaction.OccurredOn:d}, category={category.Name}{historyNote}. Recorded as a transaction.";
}

ก่อนจะไปกันต่อ Recap เล็กน้อยๆ - ผมเองก็งงเหมือนกัน 555

  • Class-based skills หาก Skill มีการเชื่อมต่อ DB ที่ซับซ้อน, ต้องการ Inject Service ผ่าน Constructor, มีโครสร้างที่ชัดเจน / Testability และ Reuse ได้
  • Code-defined skills หาก Skill ถูกสร้างขึ้นแบบ Dynamic ตอน Runtime (เช่น โครงสร้าง Skill เปลี่ยนไปตามข้อมูลใน DB), มีขนาดเล็กมาก, หรือต้องการเข้าถึง Local Variables/Closures โดยตรง
- MCP-based skills

อันนี้มี 2 ส่วน ส่วนของ MCP Server (NuGet ModelContextProtocol.AspNetCore) / MCP Client (NuGet Microsoft.Agents.AI.Mcp)

📌MCP Server - เปิด URL scheme skill://index.json โดยรองรับกาารทำงาน 2 แบบได้แก่

FormatBehaviorUse Case
skill-md MCP Server เปิดให้ Agent เข้ามาดึง
SKILL.md  รวมถึง Resource อื่นๆเอง
สำหรับให้ Agent เข้ามาดึง Skill ล่าสุด ที่ละตัว
ถ้ามี Request เพิ่ม ตัว Agent จะ Trigger ต่ออี
archiveMCP Server เปิดให้ดึง Skill ทั้งหมดออกไป
รูปแบบ .zip, .tar, .tar.gz
เหมาะสำหรับการ Sync กระจาย กลุ่ม Skill ให้
AI Agent เช่น Skill สำหรับ Finance /
Customer Support / Data Operation /
Dev เป็นต้น
  • Create Handler Resource
public sealed class MonthlySummaryResourceHandlers(
    FinanceDbContext db,
    ICurrentUserAccessor currentUserAccessor,
    ILogger<MonthlySummaryResourceHandlers> logger)
{
    private const string SkillName = "monthly-summary";
    private const string IndexUri = "skill://index.json";
    private const string SkillMdUri = "skill://monthly-summary/SKILL.md";
    private const string ResourceUriPrefix = "skill://monthly-summary/";

    // Thin wrappers around the *Core methods below, which take plain arguments rather than a
    // RequestContext<T> — ModelContextProtocol.Server.RequestContext<T>'s only constructor requires a real
    // (non-null) McpServer + JsonRpcRequest, too heavy to stand up in a unit test. Splitting the actual
    // logic out keeps it testable without a live transport, same "wrapper vs. testable core" split
    // ReceiptOcrSkillFactory uses for its own AI-facing script method (docs/spec.md §4.2).
    public static ValueTask<ListResourcesResult> ListResourcesAsync(RequestContext<ListResourcesRequestParams> _, CancellationToken __) =>
        ListResourcesCoreAsync();

    public ValueTask<ReadResourceResult> ReadResourceAsync(RequestContext<ReadResourceRequestParams> context, CancellationToken cancellationToken)
    {
        var uri = context.Params?.Uri
            ?? throw new McpException("Missing resource uri.");
        return ReadResourceCoreAsync(uri, cancellationToken);
    }

    public static ValueTask<ListResourcesResult> ListResourcesCoreAsync() =>
        ValueTask.FromResult(new ListResourcesResult
        {
            Resources =
            [
                new Resource { Uri = IndexUri, Name = "skill-index", MimeType = "application/json" },
                new Resource { Uri = SkillMdUri, Name = SkillName, MimeType = "text/markdown" },
            ],
        });

    public async ValueTask<ReadResourceResult> ReadResourceCoreAsync(string uri, CancellationToken cancellationToken)
    {
        logger.LogInformation("MCP read_skill_resource: {Uri}", uri);

        var text = uri switch
        {
            IndexUri => BuildIndexJson(),
            SkillMdUri => await ReadSkillMdAsync(cancellationToken),
            _ when uri.StartsWith(ResourceUriPrefix, StringComparison.Ordinal) =>
                await BuildSummaryJsonAsync(uri[ResourceUriPrefix.Length..], cancellationToken),
            _ => throw new McpException($"Unknown resource: {uri}"),
        };

        return new ReadResourceResult
        {
            Contents = [new TextResourceContents { Uri = uri, MimeType = "text/plain", Text = text }],
        };
    }

    private static string BuildIndexJson()
    {
        var index = new
        {
            skills = new[]
            {
                new
                {
                    name = SkillName,
                    type = "skill-md",
                    description = "Produces a monthly income/expense summary with budget-vs-actual status.",
                    url = SkillMdUri,
                    digest = "v1",
                },
            },
        };
        return JsonSerializer.Serialize(index);
    }

    private static Task<string> ReadSkillMdAsync(CancellationToken cancellationToken)
    {
        var path = Path.Combine(AppContext.BaseDirectory, "skills", "monthly-summary", "SKILL.md");
        return File.ReadAllTextAsync(path, cancellationToken);
    }

    /// <summary>
    /// <paramref name="resourceName"/> is expected as <c>summary-&lt;year&gt;-&lt;month&gt;</c> (e.g.
    /// <c>summary-2026-08</c>) — MCP resource reads don't carry free-form structured arguments the way
    /// tool calls do, so the month/year travel encoded in the resource name itself. SKILL.md tells the
    /// agent this exact convention (same "spell out the convention" approach already used for
    /// <c>skills/savings-calculator</c>'s script-path gotcha, docs/spec.md §4.3).
    /// </summary>
    private async Task<string> BuildSummaryJsonAsync(string resourceName, CancellationToken cancellationToken)
    {
        if (!TryParsePeriod(resourceName, out var period))
        {
            throw new McpException(
                $"Unrecognized resource '{resourceName}'. Expected 'summary-<year>-<month>', e.g. 'summary-2026-08'.");
        }

        // userId comes from this request's validated JWT claims (HttpUserContextAccessor), never from the
        // resource name or any other MCP-request-supplied value — the isolation boundary now lives here,
        var userId = currentUserAccessor.UserId
            ?? throw new McpException("No authenticated user for this request.");

        var summary = await MonthlySummaryRepository.GetMonthlySummaryAsync(db, userId, period, cancellationToken);

        logger.LogInformation(
            "MCP monthly-summary computed for user {UserId}, period {Period}: income={TotalIncome} expense={TotalExpense}",
            userId, period.ToString("yyyy-MM", CultureInfo.InvariantCulture), summary.TotalIncome, summary.TotalExpense);

        return JsonSerializer.Serialize(new
        {
            month = summary.PeriodMonth.ToString("yyyy-MM", CultureInfo.InvariantCulture),
            totalIncome = summary.TotalIncome,
            totalExpense = summary.TotalExpense,
            net = summary.TotalIncome - summary.TotalExpense,
            byCategory = summary.ByCategory.Select(c => new
            {
                category = c.CategoryName,
                kind = c.Kind.ToString(),
                total = c.TotalAmount,
            }),
            budgetStatuses = summary.BudgetStatuses.Select(b => new
            {
                category = b.CategoryName,
                limit = b.LimitAmount,
                spent = b.SpentAmount,
                percentUsed = b.PercentUsed,
                status = b.IsOver ? "Over" : b.IsNear ? "Near" : "Ok",
            }),
        });
    }

    private static bool TryParsePeriod(string resourceName, out DateOnly period)
    {
        period = default;
        const string prefix = "summary-";
        if (!resourceName.StartsWith(prefix, StringComparison.Ordinal))
        {
            return false;
        }

        var parts = resourceName[prefix.Length..].Split('-');
        if (parts.Length != 2
            || !int.TryParse(parts[0], NumberStyles.None, CultureInfo.InvariantCulture, out var year)
            || !int.TryParse(parts[1], NumberStyles.None, CultureInfo.InvariantCulture, out var month)
            || month is < 1 or > 12)
        {
            return false;
        }

        period = new DateOnly(year, month, 1);
        return true;
    }
}
  • สร้าง server ก่อน
builder.Services.AddMcpServer()
    .WithHttpTransport()
    .WithListResourcesHandler(MonthlySummaryResourceHandlers.ListResourcesAsync)
    .WithReadResourceHandler((context, cancellationToken) =>
        context.Services!.GetRequiredService<MonthlySummaryResourceHandlers>()
            .ReadResourceAsync(context, cancellationToken));

📌MCP Client - สร้าง mcpClient เรียกใช้ และเรียก

// Create MCP Client
 var baseUrl = configuration["Mcp:BaseUrl"]
                ?? throw new InvalidOperationException("Missing Mcp:BaseUrl configuration.");
var token = tokenIssuer.IssueToken(userId);

var transport = new HttpClientTransport(new HttpClientTransportOptions
{
    Endpoint = new Uri(baseUrl),
    Name = $"finance-mcp-{userId:N}",
                AdditionalHeaders = new Dictionary<string, string> { ["Authorization"] = $"Bearer {token}" },
 });
await using McpClient client = await McpClient.CreateAsync(transport, cancellationToken: cancellationToken);

// Build a skills provider that discovers skills over MCP
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseMcpSkills(client)
    .Build();
  • สำหรับการดึง Skill แบบ Archive Client ต้องส่ง AgentMcpSkillsSourceOptions เข้าไปด้วย เพื่อระบุขอบเขตการค้นหา และถ้ามี Script ใน Skill ตัว MAF บอก Archive scripts are never executed
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseMcpSkills(client, new AgentMcpSkillsSourceOptions
    {
        ArchiveSkillsDirectory = Path.Combine(AppContext.BaseDirectory, "extracted-skills"),
        ArchiveMaxFileCount = 50,
        ArchiveMaxSizeBytes = 2 * 1024 * 1024, // 2 MB
    })
    .Build();

ใช้ Agent Skills + Harness Agent

ตอนนี้เรารู้แล้วว่า MAF มันมี Skill ทั้งหมด 4 แบบ File-based skills / Class-based skills / Code-defined skills และ MCP-based skills ต้องเขียน Code ขึ้นมา เพื่อดึงเตรียมว่าให้กับ Agent + LLM ว่ามีอะไรที่สามารถใช้สอยได้บ้าง ซึ่งเราสามารถเขียน Code ได้ประมาณนี้

var budgetSkill = new BudgetSkill(scopeFactory, userId);
var skillsRoot = Path.Combine(AppContext.BaseDirectory, "skills");
var fileOptions = new AgentFileSkillsSourceOptions
{
    AllowedResourceExtensions = [".md", ".txt"],
    AllowedScriptExtensions = [".py"],
    SearchDepth = 3, // Search up to 3 levels deep (default is 2)
    ResourceFilter = context => context.RelativeFilePath.StartsWith("references/"),
    ScriptFilter = context => context.RelativeFilePath.StartsWith("scripts/")
                           || context.RelativeFilePath.StartsWith("tools/"),
};

// Via builder
var skillsProvider = new AgentSkillsProviderBuilder()
    .UseSkill(budgetSkill)  //1. Class-based skills  
    .UseFileSkill(Path.Combine(AppContext.BaseDirectory, "skills"), options: fileOptions)
    .UseFileScriptRunner(SubprocessScriptRunner.RunAsync)
	.UseSource(_ => new DynamicInlineSkillsSource(_dynamicSkills))
	//DisableCaching rather than snapshotted once — the whole point is that RegisterReceiptSkill
	.DisableCaching();
    .Build();
	
// Get Skill from MCP server such as monthly-summary 
_mcpClient = await mcpServerLauncher.TryStartAsync(userId, cancellationToken);
if (_mcpClient is not null)
{
	skillsBuilder = skillsBuilder.UseMcpSkills(_mcpClient, new AgentMcpSkillsSourceOptions());
}

เนื่องจาก Skill บางตัวมันแตะ Enviroment จากเดิมที่เราสร้าง Agent แบบปกติ เอาส่วนของ Harness Agent เข้ามาช่วย โดยเอาส่วน Approve เข้ามาช่วย ซึ่งมีการตั้งค่าที่จำเป็น 2 ส่วน

  • UseOptions - DisableLoadSkillApproval / DisableReadSkillResourceApproval / DisableRunSkillScriptApproval
  • ToolApprovalAgentOptions
 var skillsProvider = skillsBuilder
            .UseOptions(o =>
            {
                o.DisableLoadSkillApproval = true;
                o.DisableReadSkillResourceApproval = true;
                o.DisableRunSkillScriptApproval = false;   //Required Approval Rule for running skill scripts ToolApprovalAgentOptions
            })
            .Build();

        // Wired into AgentFactory's HarnessAgentOptions.ToolApprovalAgentOptions
        // an approval prompt now that DisableRunSkillScriptApproval is false above.
        var toolApprovalOptions = new ToolApprovalAgentOptions
        {
            AutoApprovalRules =
            [
                // Skill Read Only By Pass 
                AutoApprovalRules = [AgentSkillsProvider.ReadOnlyToolsAutoApprovalRule],
                // If you wanyt to custom such as read from config 
                // SkillApprovalPolicy.BuildAutoApprovalRule(user.AutoApproveWrites, user.AutoApproveExecuteScript),
            ],
        };

ดังนั้นแล้วจากเดิม ตัว ChatClient ที่ใช้แต่ AsAIAgent

chatClient.AsAIAgent(options, loggerFactory);

ปรับเป็น AsHarnessAgent โดยอันนี้ผมได้ทำ Helper สำหรับสร้าง harness agent โดยมีการส่ง skillsProvider / instructions / ToolApprovalAgentOptions

อ๋อ แล้วอย่าลืมนะ ตอนสร้าง Agent อย่าลืมส่ง LoggerFactory เพื่อให้ MAF มัน Write Log นะ

public AIAgent CreateAgent(AgentSkillsProvider skillsProvider, string? instructions = null, ToolApprovalAgentOptions? toolApprovalOptions = null)
{
      #pragma warning disable MAAI001 // HarnessAgentOptions is evaluation-purposes-only in this package version. DisableCompaction Error
      var options = new HarnessAgentOptions
      {
            ChatOptions = instructions is null ? null : new ChatOptions { Instructions = instructions },
            AIContextProviders = [skillsProvider],
            DisableAgentSkillsProvider = true,
            ToolApprovalAgentOptions = toolApprovalOptions,
            DisableCompaction = true,
            DisableFileMemory = true,
            DisableWebSearch = true,
            DisableTodoProvider = true,
            DisableAgentModeProvider = true,
            DisableOpenTelemetry = true,
      };

      #pragma warning restore MAAI001
      return chatClient.AsHarnessAgent(options, loggerFactory);
  }

Sample App - gitea-aihook

สำหรับใน Session ที่ผมได้ไปพูด จะมี Demo App 2 ตัว

💡gitea-aihook - ช่วงก่อนสงกรานต์ โจทย์ผมอยากมีตัวสรุปแบบ copilot review ของ GitHub กับ GitTea เลยลองมา NET10 + WebAPI โดยสามารถ Trigger / WebHook หรือ GitTea Action เพื่อมาสรปใน PR ว่า มันมีอะไรที่คน Review ต้องระวังบ้าง โดยมี่ Skill 4 อัน

  • review - file-based skill + scirpt impact_graph เพื่อที่ใช้วิเคราะห์ git diff หาจุดที่อาจกระทบส่วนอื่นของระบบ เช่น Symbols  / API ก่อนให้ AI สรุป PR
  • security-checker เป็น file-based skill ตรวจ secret/API key ที่ hardcode, authentication/encryption แต่จริงๆควรทำเป็น script นะ
  • style-guard เป็น file-based skill ตรวจ code style, complexity, naming
  • gitea-tools เป็น custom class skill คือ GiteaSkill : AgentClassSkill<GiteaSkill> expose tool/function ให้ agent เรียก Gitea API เพิ่มเติมได้ 3 ตัว: get_issue / get_issue_comments / search_code

อ๋อ ระหว่างผมเตรียม Present ลองไปดูเออ Gitea เค้ามี MCP ด้วยนะ อาจจะเอา MCP มาใช้ได้ ไม่ต้องสร้าง custom class skill

Sample App - MyFinanceWithAgentSkill

💡อีกอัน อันนี้ลองกลับไปดู Doc ของ Agent Skill มันมีส่วน Harness / Code-defined skills และ MCP Base Skill เลยลอง App ง่ายๆ พวกการจัดการเงินแบบง่ายขึ้นมาบน .NET 10 + Blazor Server (Interactive Server) และ MudBlazor มี Feature หลักที่นำ Skill แต่ละแบบของ Microsoft Agent Framework มาใช้ เพื่อสาธิตความสามารถนี้ end-to-end ในสถานการณ์จริง ได้แก่

  • Transactions & Budgeting — class-based AgentClassSkill<T> (บันทึกใช้จ่าย ตรวจสถานะงบประมาณ โอนงบระหว่างหมวด)
  • Receipt OCR — inline AgentInlineSkill ที่สร้างต่อใบเสร็จหนึ่งไฟล์ session ได้โดยไม่ต้อง reset Chat ที่คุยกันอยู่
  • Savings Goals — file-based SKILL.md + references/ + Python (ดอกเบี้ยทบต้น)
  • Monthly Summary / Goals Progress — MCP-based ผ่าน HTTP service + JWT bearer เป็นการเรียกใช้ Skill รวมถึง Provide Resource อาทิ เอกสาร Reference หรือ API สำหรับคำนวณที่ฝั้ง MCP Server
  • Emergency Fund / Debt Payoff - MCP-based ผ่าน HTTP service + JWT bearer โดย อันนี้เป็นการแสดงตัวอย่างแบบ Archive Mode แชร์ Skill ให้ Agent ดึงลงไป Run ที่ตัวเอง
กรณีที่เป็นการของ Skill แบบ Archive มันจะไป Download จาก MCP Server มานะ แต่ยังมีข้อจำกัด เรื่องการ Clear ของเดิม

หน้าจอหลัก ได้แก่

ตัวอย่างแสดงการเรียกใช้ Skill และถ้ามี Approve / Reject มีหน้าจอขึ้นให้ถาม
  • Chat (streaming, render Markdown ผ่าน Markdig, Approve/Reject ในส่วน Harness สำหรับ run_skill_script), Agent Activity Log (แสดง chip กิจกรรมของ skill แบบเรียลไทม์)
  • พวก CRUD อย่าง Receipt, Goals, Transactions, Budgets
  • และระบบ User ASP.NET Core Identity ซึ่งในส่วนนี้ มีส่วน Config ของแต่ละ User ถ้าให้มากด Approve รัวๆ มันคงรำคาญ เลยมี Flag AutoApproveWrites / AutoApproveExecuteScript ต่อผู้ใช้ ก่อนเรียกใช้ load_skill และ read_skill_resource ทำงาน

Security best practices

  • Review Before Use - Skill มันมี Script ด้วย อ่านก่อนใช้งาน
  • Trust Source - เอาจากแหล่งที่น่าเชื่อถือ ระวัง "typosquatted" skill name สะกดคล้ายกัน rnicrosoft vs microsoft
  • Pin versions & verify integrity - ของ Skill ใน MCP Server
  • Sandboxing - มันมี Script ถ้าให้มันทำงานควรจะแยก Env ไว้ และมีส่วน Agent Harness (Approval)
  • Apply least privilege
  • Treat skill content as untrusted input
    - Keep secrets out of skills - อย่าสง Secret ให้ Skill มันทำ ถ้าจะส่งต้อง Review Script ข้างในว่ามีแอบไปส่งอะไรไหม
    - Never embed credentials — inject them at runtime (e.g., per-user JWT to the MCP server) อันนี้ตัวอย่างใน Code มีนะ
  • Approval gates - Human In The Loop เสมอ
  • Audit and logging - มัน load อะไรมา

สุดท้ายขอบคุณทุกท่านที่มาฟังคับ ถ้าตอนไฟแลบมาอ่านใน Blog เพิ่มได้ และพวก Sample App ถ้ามีเวลา น่าจะเขียน Blog ขยายอีกทีครับ สำหรับ Resource ทั้งหมด

Reference