Table of Contents

Use MCP Resources and Dynamic Prompts in Agent Workflows

The Model Context Protocol offers more than tool calling. MCP servers can expose resources (structured data your application can read), prompts (reusable message templates with arguments), sampling (server-initiated LLM requests), elicitation (server-initiated user input requests), and diagnostic features such as progress tracking and logging. This guide walks through every MCP capability beyond tools, showing how to integrate each one into your LM-Kit.NET application.


Why This Matters

Two enterprise problems that advanced MCP capabilities solve:

  1. Centralize configuration and data access. Resources let MCP servers publish files, database records, and live configuration as first-class data that agents can read on demand. Instead of hard-coding data-fetching logic in every tool, agents discover and consume resources through a standard protocol.
  2. Standardize prompt engineering across teams. Prompt templates stored on an MCP server can be versioned, shared, and rendered with arguments at runtime. Teams reuse the same prompts without copy-pasting system instructions into every agent, reducing drift and making updates instant.

Prerequisites

Requirement Minimum
.NET SDK 8.0+
VRAM 6+ GB (for sampling step; not needed for resource/prompt only workflows)
Disk ~5 GB free for model download (sampling step only)
MCP server A running MCP server that exposes resources, prompts, or both

Step 1: Create the Project

dotnet new console -n McpResourcesDemo
cd McpResourcesDemo
dotnet add package LM-Kit.NET

Step 2: Understand MCP Capabilities Beyond Tools

The first MCP guide covered Tools: functions the agent can call. MCP servers advertise additional capabilities that unlock richer integrations.

┌─────────────────────────────────────────────────────────────────┐
│                     MCP Server Capabilities                     │
├───────────────┬─────────────────────────────────────────────────┤
│               │                                                 │
│  Tools        │  Resources   Prompts   Logging   Completions   │
│  (covered in  │  (this guide)                                   │
│   prior       │                                                 │
│   guide)      │  Sampling    Elicitation    Progress            │
│               │  (server requests handled by client)            │
└───────────────┴─────────────────────────────────────────────────┘

Each capability is advertised by the server during initialization and represented as a flag in the McpServerCapabilities enum:

Flag Description
Tools Server exposes callable functions
Resources Server publishes readable data (files, records, live config)
Prompts Server offers reusable prompt templates with arguments
Logging Server can stream log messages to the client
Completions Server supports argument autocompletion suggestions

Sampling and elicitation are client capabilities: the server requests LLM completions or user input, and your client responds.


Step 3: Connect to an MCP Server and Check Capabilities

using System.Text;
using LMKit.Mcp.Client;
using LMKit.Mcp.Abstractions;

// Set your license key (leave empty for trial)
LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Build the client
// ──────────────────────────────────────
using var client = McpClientBuilder
    .ForHttp("https://mcp.example.com/api")
    .WithBearerToken(Environment.GetEnvironmentVariable("MCP_API_KEY"))
    .WithClientInfo("MyApp", "My Application", "1.0.0")
    .Build();

await client.InitializeAsync();

Console.WriteLine($"Protocol: {client.McpProtocolVersion}");
Console.WriteLine($"Session:  {client.SessionId}");
Console.WriteLine();

// ──────────────────────────────────────
// 2. Check which capabilities are available
// ──────────────────────────────────────
Console.WriteLine("Server capabilities:");

if (client.HasCapability(McpServerCapabilities.Resources))
    Console.WriteLine("  Resources:   supported");
if (client.HasCapability(McpServerCapabilities.Prompts))
    Console.WriteLine("  Prompts:     supported");
if (client.HasCapability(McpServerCapabilities.Logging))
    Console.WriteLine("  Logging:     supported");
if (client.HasCapability(McpServerCapabilities.Completions))
    Console.WriteLine("  Completions: supported");

Console.WriteLine();

HasCapability performs a bitwise check against the flags the server returned during initialization. Always verify a capability before calling methods that depend on it.


Step 4: List and Read Resources

Resources are server-managed data items identified by URIs. Each resource has a name, MIME type, and optional description.

// ──────────────────────────────────────
// 1. List all resources
// ──────────────────────────────────────
var resources = await client.GetResourcesAsync();

foreach (var resource in resources)
{
    Console.WriteLine($"Resource: {resource.Name}");
    Console.WriteLine($"  URI:         {resource.Uri}");
    Console.WriteLine($"  MIME type:   {resource.MimeType}");
    Console.WriteLine($"  Description: {resource.Description}");
    Console.WriteLine();
}

// ──────────────────────────────────────
// 2. Read a specific resource by URI
// ──────────────────────────────────────
var contents = await client.ReadResourceAsync("file:///project/config.json");

foreach (var item in contents)
{
    Console.WriteLine($"  URI:  {item.Uri}");
    Console.WriteLine($"  MIME: {item.MimeType}");

    if (item.Text != null)
        Console.WriteLine($"  Text content ({item.Text.Length} chars)");
    else if (item.Blob != null)
        Console.WriteLine($"  Binary content ({item.Blob.Length} bytes, base64-encoded)");
}

GetResourcesAsync returns IReadOnlyCollection<McpResource> and caches the result after the first call. ReadResourceAsync returns IReadOnlyList<McpResourceContent>, where each entry contains either Text (for textual data) or Blob (for base64-encoded binary data).


Step 5: Use Resource Templates for Dynamic Content

Resource templates define parameterized URI patterns (RFC 6570). The client fills in the template variables to request dynamic data.

// ──────────────────────────────────────
// 1. Discover available templates
// ──────────────────────────────────────
var templates = await client.GetResourceTemplatesAsync();

foreach (var template in templates)
{
    Console.WriteLine($"Template: {template.Name}");
    Console.WriteLine($"  URI pattern:  {template.UriTemplate}");   // e.g. "db://users/{userId}"
    Console.WriteLine($"  Description:  {template.Description}");
    Console.WriteLine($"  MIME type:    {template.MimeType}");
    Console.WriteLine();
}

// ──────────────────────────────────────
// 2. Read a dynamic resource by filling in the template
// ──────────────────────────────────────
var userData = await client.ReadResourceAsync("db://users/12345");

foreach (var item in userData)
{
    if (item.Text != null)
        Console.WriteLine($"User data: {item.Text}");
}

The client does not expand templates automatically. You construct the final URI yourself by replacing the {placeholder} segments with actual values, then pass it to ReadResourceAsync.


Step 6: Subscribe to Resource Updates

When the server supports resource subscriptions, your application can receive push notifications when a resource changes.

// ──────────────────────────────────────
// 1. Attach a handler for updates
// ──────────────────────────────────────
client.ResourceUpdated += (sender, args) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"[{args.Timestamp:HH:mm:ss}] Resource updated: {args.Uri}");
    Console.ResetColor();

    // Re-read the resource to get fresh content
    var fresh = client.ReadResource(args.Uri);
    foreach (var item in fresh)
    {
        if (item.Text != null)
            Console.WriteLine($"  New content: {item.Text.Substring(0, Math.Min(200, item.Text.Length))}...");
    }
};

// ──────────────────────────────────────
// 2. Subscribe to a resource
// ──────────────────────────────────────
await client.SubscribeToResourceAsync("file:///project/config.json");
Console.WriteLine("Subscribed. Waiting for updates...");

// Check active subscriptions
var subscribed = client.SubscribedResources;
Console.WriteLine($"Active subscriptions: {subscribed.Count}");

// ──────────────────────────────────────
// 3. Unsubscribe when done
// ──────────────────────────────────────
await client.UnsubscribeFromResourceAsync("file:///project/config.json");
Console.WriteLine("Unsubscribed.");

The ResourceUpdated event fires when the server pushes a notifications/resources/updated message. Subscription support depends on the server; SubscribeToResourceAsync throws InvalidOperationException if the server does not advertise the subscribe capability.


Step 7: Discover and Render Dynamic Prompts

MCP prompts are reusable message templates stored on the server. Each prompt declares arguments that the client fills in before rendering.

// ──────────────────────────────────────
// 1. List available prompts
// ──────────────────────────────────────
var prompts = await client.GetPromptsAsync();

foreach (var prompt in prompts)
{
    Console.WriteLine($"Prompt: {prompt.Name}");
    Console.WriteLine($"  Title:       {prompt.Title}");
    Console.WriteLine($"  Description: {prompt.Description}");

    foreach (var arg in prompt.Arguments)
    {
        string required = arg.Required == true ? "required" : "optional";
        Console.WriteLine($"  Arg: {arg.Name} ({required}) {arg.Description}");
    }
    Console.WriteLine();
}

// ──────────────────────────────────────
// 2. Render a prompt with arguments
// ──────────────────────────────────────
var result = await client.GetPromptAsync("draft_email", new Dictionary<string, object>
{
    { "topic", "Quarterly review meeting" },
    { "recipient", "Engineering Team" }
});

Console.WriteLine($"Description: {result.Description}");
Console.WriteLine($"Messages: {result.Messages.Count}");

foreach (var message in result.Messages)
{
    Console.WriteLine($"\n[{message.Role}]");

    foreach (var part in message.Content)
    {
        if (part.Type == "text")
            Console.WriteLine(part.Text);
        else if (part.Type == "resource")
            Console.WriteLine($"  Embedded resource: {part.Resource.Uri}");
        else if (part.Type == "image")
            Console.WriteLine($"  Image: {part.MimeType} ({part.Data?.Length ?? 0} chars base64)");
    }
}

GetPromptAsync sends a prompts/get request with the argument map and returns an McpPromptResult containing pre-formatted messages. Each message has a Role (User or Assistant) and a list of McpPromptContent parts that may be text, embedded resources, images, or audio.


Step 8: Handle Sampling Requests from the Server

Sampling is a server-to-client request: the MCP server asks your application to run an LLM completion. This enables agentic behaviors where the server delegates reasoning to the client's local model.

using LMKit.Model;
using LMKit.TextGeneration;

// ──────────────────────────────────────
// 1. Load a model for sampling
// ──────────────────────────────────────
Console.WriteLine("Loading model for sampling...");
using LM model = LM.LoadFromModelID("qwen3:8b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Option A: Register a handler function
// ──────────────────────────────────────
client.SetSamplingHandler(async (request, cancellationToken) =>
{
    var conversation = new SingleTurnConversation(model);

    if (request.SystemPrompt != null)
        conversation.SystemPrompt = request.SystemPrompt;

    if (request.MaxTokens > 0)
        conversation.MaximumCompletionTokens = request.MaxTokens;

    string prompt = request.Messages[0].Content.Text;
    var completionResult = conversation.Submit(prompt);

    return McpSamplingResponse.FromText(
        completionResult.Completion,
        model: "qwen3:8b",
        stopReason: McpStopReason.EndTurn);
});

// ──────────────────────────────────────
// 3. Option B: Use the event (alternative)
// ──────────────────────────────────────
// client.SamplingRequested += (sender, args) =>
// {
//     // Accept with a quick text response
//     args.Accept("Here is the generated response.", model: "qwen3:8b");
//
//     // Or reject the request
//     // args.Reject("Sampling not allowed for this request type");
// };

The handler function takes precedence. If the handler returns null, the SamplingRequested event fires as a fallback. If neither produces a response, the client sends an error back to the server.


Step 9: Handle Elicitation Requests (User Input)

Elicitation is the server asking your application to collect input from the user. The server describes what it needs using a JSON schema, and your code gathers the data and responds.

// ──────────────────────────────────────
// 1. Attach the elicitation handler
// ──────────────────────────────────────
client.ElicitationRequested += (sender, args) =>
{
    Console.ForegroundColor = ConsoleColor.Yellow;
    Console.WriteLine($"Server requests input: {args.Request.Message}");
    Console.ResetColor();

    if (!string.IsNullOrEmpty(args.Request.RequestedSchema))
        Console.WriteLine($"Schema: {args.Request.RequestedSchema}");

    // Collect user input (in a real application, render a form)
    Console.Write("Enter value: ");
    string userInput = Console.ReadLine();

    if (string.IsNullOrWhiteSpace(userInput))
    {
        // User chose not to provide input
        args.Decline();
        return;
    }

    args.Accept(new Dictionary<string, object>
    {
        { "response", userInput }
    });
};

// ──────────────────────────────────────
// 2. Or use a handler function
// ──────────────────────────────────────
// client.SetElicitationHandler((request, cancellationToken) =>
// {
//     Console.WriteLine($"Elicitation: {request.Message}");
//     // Return acceptance, decline, or cancellation
//     return Task.FromResult(McpElicitationResponse.Accept(
//         new Dictionary<string, object> { { "response", "auto-filled value" } }
//     ));
// });

Three responses are available: Accept (with a content dictionary matching the schema), Decline (user refused), and Cancel (operation cancelled). If no handler or event is registered, the client automatically sends a Cancel response.


Step 10: Track Progress and Monitor Logs

MCP servers can push progress notifications for long-running operations and stream log messages for diagnostics.

// ──────────────────────────────────────
// 1. Progress tracking
// ──────────────────────────────────────
client.ProgressReceived += (sender, args) =>
{
    string pct = args.Percentage.HasValue ? $" ({args.Percentage:F1}%)" : "";
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  Progress: {args.Progress}/{args.Total}{pct} {args.Message}");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 2. Set log level and listen for messages
// ──────────────────────────────────────
if (client.HasCapability(McpServerCapabilities.Logging))
{
    await client.SetLogLevelAsync(McpLogLevel.Info);

    client.LogMessageReceived += (sender, args) =>
    {
        Console.ForegroundColor = ConsoleColor.DarkGray;
        Console.WriteLine($"  [{args.Level}] {args.Logger}: {args.Data}");
        Console.ResetColor();
    };
}

// ──────────────────────────────────────
// 3. Protocol-level diagnostics
// ──────────────────────────────────────
client.Sending += (sender, args) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  >>> {args.Method}");
    Console.ResetColor();
};

client.Received += (sender, args) =>
{
    Console.ForegroundColor = ConsoleColor.DarkGray;
    Console.WriteLine($"  <<< {args.Method} (HTTP {args.StatusCode})");
    Console.ResetColor();
};

// ──────────────────────────────────────
// 4. React to catalog changes
// ──────────────────────────────────────
client.ResourcesChanged += (sender, _) =>
{
    Console.WriteLine("  Resource catalog changed. Refreshing...");
};

client.PromptsChanged += (sender, _) =>
{
    Console.WriteLine("  Prompt catalog changed. Refreshing...");
};

SetLogLevelAsync sends a logging/setLevel request to the server, telling it to filter messages below the specified severity. The McpLogLevel enum follows syslog levels: Debug, Info, Notice, Warning, Error, Critical, Alert, Emergency.

Progress events include Progress (current value), Total (expected total, if known), Percentage (computed automatically), and Message (optional description).


Common Issues

Problem Cause Fix
InvalidOperationException on SubscribeToResourceAsync Server does not support resource subscriptions Check the server's capabilities before subscribing. Not all servers that support Resources also support subscriptions.
GetResourcesAsync returns empty Server has no resources published Verify the server exposes resources. Check HasCapability(McpServerCapabilities.Resources) first.
Sampling handler never called Server did not request sampling Sampling is server-initiated. Your handler only runs when the server sends a sampling/createMessage request.
SetLogLevelAsync throws Server does not advertise logging Guard with HasCapability(McpServerCapabilities.Logging) before calling.
Prompt arguments ignored Wrong argument names Check McpPrompt.Arguments for the exact names and required status the server expects.
ResourceUpdated event not firing Not subscribed to the resource Call SubscribeToResourceAsync for each URI you want to monitor. The event only fires for subscribed resources.

Next Steps