Bit of Technology

  • Archive
  • About Me
    • Advertise
    • Disclaimer
  • Speaking
  • Contact

Part 3: MCP Tools, Resources and Prompts in .NET

October 2, 2026 By Taiseer Joudeh Leave a Comment

Share this:

  • Share on X (Opens in new window) X
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on Reddit (Opens in new window) Reddit
  • Share on Facebook (Opens in new window) Facebook
  • Email a link to a friend (Opens in new window) Email

This is the third part of Building MCP Servers in .NET for AI Agents. The topics we’ll cover are:

  • What is the Model Context Protocol? An Introduction for .NET Developers
  • Building Your First MCP Server in C#
  • MCP Tools, Resources and Prompts in .NET (This Post)
  • Wiring MCP Tools to Azure SQL and External APIs
  • Hosting an MCP Server in ASP.NET Core with Streamable HTTP
  • Building an MCP Client and AI Agent in .NET
  • Securing an MCP Server with Microsoft Entra ID
  • Deploying an MCP Server to Azure Container Apps
  • Connecting an MCP Server to Microsoft Foundry Agent Service
  • Observability, Cost Control and MCP Security

The source code for this tutorial is available on GitHub.

MCP Tools, Resources and Prompts in .NET

Part 2 finished with a server that answers a simple question wrongly. We’re going to fix that first, because the fix is more interesting than it sounds, and then add the two primitives we haven’t touched yet.

By the end of this post the server exposes a company expense policy as a resource, offers a policy review as a slash command in Copilot, and answers the question it previously got wrong.

Where we left off

Our server has a tool called search_expense_reports with two parameters, employeeName and status. Ask Copilot this:

C#
1
Which expense reports has Lina submitted?

Lina has one report. She filed it on 28 August and it was later rejected. But “submitted” is doing two jobs in that sentence, and the model has to guess which one you meant:

  1. Reports Lina has filed at any point, which is one report.
  2. Reports currently sitting in Submitted status, which is none.

Pick the second and the tool returns an empty array, and the answer comes back as “Lina hasn’t submitted any expense reports”. Nothing errored. The query did exactly what it was told.

Fixing it

I started by rewriting the description. That helped a bit. What actually fixed it was renaming the parameter.

The model only sees three things about your tool: the name, the descriptions, and the JSON Schema. The parameter name sits inside that schema. So status reads as “the status the user mentioned”, while currentStatus reads as “the status right now”.

So we do both. Open Tools/ExpenseTools.cs and change the search tool:

C#
1
2
3
4
5
6
7
[McpServerTool(Name = "search_expense_reports")]
[Description("Searches expense reports by employee and by where they are in the approval workflow. Returns the matching reports with their line items.")]
public IReadOnlyList<ExpenseReport> SearchExpenseReports(
    [Description("Part of an employee name, matched case insensitively. Omit to search across all employees.")]
    string? employeeName = null,
    [Description("Where the report sits in the approval workflow right now. Only use this when the question is about the current state. A report that was approved or rejected was also submitted at some point, so do not filter on Submitted to answer questions about what somebody has submitted.")]
    ExpenseReportStatus? currentStatus = null)

Rename the variable in the body too, from status to currentStatus. That’s the whole change.

What we have done here is the following:

  • Renamed the parameter so the schema itself says “current”, which removes most of the ambiguity before a single word of description is read.
  • Told the model when not to use the parameter. Most descriptions only say what something does. This one names the specific mistake and rules it out.
  • Left the method description broader, so it covers searching by person as well as by state.

While we’re in this file, the other tool’s description gets a second sentence, and its error message gets a job to do:

C#
1
2
3
4
5
6
7
8
9
[McpServerTool(Name = "get_expense_report")]
[Description("Gets one expense report with its line items, by id. Call search_expense_reports first if you do not already have an id.")]
public ExpenseReport GetExpenseReport(
    [Description("The expense report id, for example 1001.")] int reportId)
{
    return store.Reports.FirstOrDefault(r => r.Id == reportId)
        ?? throw new McpException(
            $"No expense report found with id {reportId}. Call search_expense_reports to find valid ids.");
}

Remember from Part 2 that McpException is the one exception type whose message reaches the model. That makes an error message a place to give instructions, not just to report a fact. “No report found” is a dead end. “No report found, call this other tool to find valid ids” is a route out.

Writing better tools

The same thinking applies to any tool you write:

  1. Name a parameter after what it means. currentStatus says what the value is. status leaves it open.
  2. Say when not to use the tool. Most descriptions only say what a tool does. Add a line about when it’s the wrong choice.
  3. Use real types. An enum becomes a fixed list of values in the schema, so the model can’t make one up. A string lets it try anything.
  4. Write errors for the model to read. Tell it what to do next, not just what went wrong.
  5. Keep the number of tools down. Every tool name, description and schema is sent on every request. Twenty tools means twenty descriptions to read through, every time.

None of this is complicated. You’re writing for a model instead of a teammate, and that changes what a good sentence looks like.

Tools, resources and prompts, and who invokes them

Now for the other two primitives. Part 1 described the difference in terms of control, and the practical version of that is simply who starts the interaction:

PrimitiveWho invokes itHow, in VS Code
ToolThe modelAutomatically, in Agent mode
ResourceYouAdd Context, then Select MCP Resource
PromptYou/mcp.SERVER.PROMPT

That last column is why the distinction matters. A tool is something you hope the model will use. A resource and a prompt are things you choose.

Building it

Step 1: Add the expense policy document

Our resource is going to be a real document, so let’s write one. Create Resources/policy.md in the server project:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# BitOfTech Expense Policy
 
Version 4.1, effective 1 January 2026. Applies to all employees and contractors.
 
## 1. General rules
 
1. Claim in the currency you actually paid in. Finance converts to GBP using the published
   reference rate for the date the expense was incurred, not the date you filed the report.
2. Submit a report within 30 days of the earliest expense on it. Reports submitted after
   30 days need written approval from a director.
3. Attach a receipt for every line over 75.00 GBP, or the equivalent in the currency claimed.
4. One report per trip or per calendar month. Do not mix a trip with routine monthly costs.
 
## 3. Accommodation
 
1. The limit is 180.00 GBP per night including tax, for all locations.
2. London, Zurich, New York, San Francisco, Tokyo and Singapore carry a higher limit of
   260.00 GBP per night.
 
## 4. Meals
 
1. Meals while travelling are reimbursed up to 60.00 GBP per person per day.
4. Any meal line over 150.00 GBP must be itemised per person.
 
## 6. Communications and equipment
 
2. Home broadband is not reimbursable. It is covered by the monthly remote working allowance
   and must not appear on an expense report.

I’ve shortened this to the rules we need later on. The full policy in the repo has seven sections.

One thing worth planning here. Make sure some of your sample expense reports actually break some of these rules. If every report passes, the review we run at the end of this post has nothing to say and the demo falls flat.

Then tell the build to copy it to the output folder, by adding this to the csproj:

C#
1
2
3
<ItemGroup>
  <Content Include="Resources\policy.md" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>

Step 2: Expose it as a resource

Create Resources/ExpenseResources.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
using BitOfTech.Expenses.Mcp.Data;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
 
namespace BitOfTech.Expenses.Mcp.Resources;
 
[McpServerResourceType]
public class ExpenseResources(ExpenseStore store)
{
    private static readonly Lazy<string> PolicyText = new(() =>
        File.ReadAllText(Path.Combine(AppContext.BaseDirectory, "Resources", "policy.md")));
 
    private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
    {
        WriteIndented = true,
        Converters = { new JsonStringEnumConverter() }
    };
 
    [McpServerResource(UriTemplate = "expenses://policy", Name = "Expense policy", MimeType = "text/markdown")]
    [Description("The BitOfTech expense policy. Attach this when you need to judge whether a report follows company rules.")]
    public static string GetExpensePolicy() => PolicyText.Value;
 
    [McpServerResource(UriTemplate = "expenses://reports/{reportId}", Name = "Expense report", MimeType = "application/json")]
    [Description("A single expense report as JSON, addressed by its id. Attach this when you already know which report you want to talk about.")]
    public TextResourceContents GetExpenseReport(int reportId)
    {
        var report = store.Reports.FirstOrDefault(r => r.Id == reportId)
            ?? throw new McpException($"No expense report found with id {reportId}.");
 
        return new TextResourceContents
        {
            Uri = $"expenses://reports/{reportId}",
            MimeType = "application/json",
            Text = JsonSerializer.Serialize(report, JsonOptions)
        };
    }
}

What we have done here is the following:

  • [McpServerResourceType] and [McpServerResource] mirror the tool attributes from Part 2. The SDK discovers them the same way.
  • UriTemplate is the identity of the resource. expenses:// is a scheme we invented. You can use anything, and a scheme named after your domain is easier to read in a client than file:// or app://.
  • MimeType tells the client what it’s getting. Markdown renders as markdown, JSON is treated as data.
  • The first method is static because it needs nothing. The second takes ExpenseStore through the constructor, exactly like the tools do.
  • The second UriTemplate has a {reportId} placeholder in it. That makes it a resource template, and it behaves differently, which we come back to in Step 5.

Step 3: Register the new primitives

Two lines in Program.cs:

C#
1
2
3
4
5
6
builder.Services
    .AddMcpServer()
    .WithStdioServerTransport()
    .WithToolsFromAssembly()
    .WithResourcesFromAssembly()
    .WithPromptsFromAssembly();

The assembly scan works the same way for all three. Restart the server. The handshake now advertises logging, prompts, resources, tools, where Part 2 advertised only logging, tools.

Step 4: Attach the policy in VS Code

Restart bitoftech-expenses, open Copilot Chat, and use Add Context. There’s an entry for MCP resources, and inside it you’ll find both of ours:

Pick the expense policy and it lands in your context as an attachment. Now ask:

C#
1
Does a 200 GBP per night hotel in Manchester comply with the policy?

The answer comes out of the document rather than out of the model’s assumptions. Manchester isn’t on the list of cities with the raised limit, so 200 breaches the 180 cap in rule 3.1.

Notice what didn’t happen. The model didn’t decide to fetch the policy. You attached it, before asking anything, and the model had no say. That’s the whole difference between a resource and a tool.

Step 5: Understand the template

Look again at that picker. VS Code lists the two differently:

  • Expense policy shows its URI, expenses://policy.
  • Expense report is labelled Resource template: expenses://reports/{reportId}.

A direct resource is one thing with one address. A template is a pattern, because there are as many expense reports as there are expense reports, and listing them all would be absurd. The client fills in the blank to get expenses://reports/1003.

Here’s the part worth sitting with. We now expose report 1003 two ways: through the get_expense_report tool, and through expenses://reports/1003 as a resource. That looks like duplication and isn’t.

  • Through the tool, the model decides. You ask a question, and it fetches the report if it judges that useful. It might fetch the wrong one, or none at all.
  • Through the resource, you decide. You attach report 1003 and then ask your question. There’s no judgement involved and nothing to get wrong.

Both are legitimate. Offer the tool for open questions where you don’t know in advance what’s relevant. Offer the resource for when the user already knows exactly what they want to talk about.

Try the template for yourself. From the same picker, attach the expense policy and the expense report, giving 1003 as the id. Then ask:

C#
1
Is this report violating any policy?

You get a proper answer. Now notice what you had to do to get it. You picked the right report, and you remembered to bring the policy along. If you forget the policy, the model has nothing to check against. That is the problem a prompt solves, and it’s the next step.

Step 6: Add the review prompt

A prompt is a parameterised message template that the user invokes. Create Prompts/ExpenseReviewPrompt.cs:

C#
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
[McpServerPromptType]
public class ExpenseReviewPrompt(ExpenseStore store)
{
    [McpServerPrompt(Name = "review_expense_report")]
    [Description("Reviews one expense report against the BitOfTech expense policy and lists anything that breaks the rules.")]
    public IEnumerable<PromptMessage> ReviewExpenseReport(
        [Description("The expense report id to review, for example 1003.")] int reportId)
    {
        var report = store.Reports.FirstOrDefault(r => r.Id == reportId)
            ?? throw new McpException($"No expense report found with id {reportId}.");
 
        return
        [
            new PromptMessage
            {
                Role = Role.User,
                Content = new TextContentBlock
                {
                    Text = """
                        You are reviewing an expense report for BitOfTech.
 
                        Work through the report line by line against the policy below. For every
                        breach, quote the rule number, the line it applies to and the amount. If a
                        line is fine, do not mention it. Finish with a single recommendation of
                        approve, reject, or return for more information.
                        """
                }
            },
            new PromptMessage
            {
                Role = Role.User,
                Content = new EmbeddedResourceBlock
                {
                    Resource = new TextResourceContents
                    {
                        Uri = "expenses://policy",
                        MimeType = "text/markdown",
                        Text = PolicyText.Value
                    }
                }
            },
            new PromptMessage
            {
                Role = Role.User,
                Content = new EmbeddedResourceBlock
                {
                    Resource = new TextResourceContents
                    {
                        Uri = $"expenses://reports/{reportId}",
                        MimeType = "application/json",
                        Text = JsonSerializer.Serialize(report, JsonOptions)
                    }
                }
            }
        ];
    }
}

Let’s review the parts:

  • A prompt returns messages, not a string. Here we return three: the instructions, the policy, and the report.
  • EmbeddedResourceBlock puts a resource inside the prompt. The user doesn’t have to remember to attach the policy, because invoking the prompt brings it along.
  • PromptMessage is the protocol type. You can return ChatMessage from Microsoft.Extensions.AI instead for plain text and images, but embedded text resources need PromptMessage.
  • The raw string literal keeps the instructions readable in the source. That text is the actual prompt, and it deserves as much care as the tool descriptions.

Note: prompt arguments travel as strings on the wire, even when your parameter is an int. The SDK converts for you, so reportId stays typed in C# and you don’t have to parse anything.

Step 7: Run it

Restart the server, then type / in Copilot Chat. The command is namespaced:

C#
1
/mcp.bitoftech-expenses.review_expense_report

The pattern is /mcp. followed by the server key from your mcp.json, then the prompt name. Worth knowing before you name a server bitoftech-expenses-mcp-server-prod, because your users have to type it.

Run it against report 1003, the London team offsite. You get back something like:

  1. Rule 4.4 breached. Team meals of 420.00 GBP exceed 150.00 and aren’t itemised per person.
  2. Rule 4.1 unverifiable. The limit is 60.00 per person per day, and the report doesn’t say how many people ate. It only passes if there were seven or more.
  3. Rule 1.3 unverifiable. All three lines exceed 75.00 GBP and no receipts are referenced.
  4. Accommodation at 229.67 per night passes, but only because rule 3.2 raises the London limit to 260.00.

I want to point at something in that output. The model flagged that the hotel passes only under the London exception, and that the meals line can’t be judged without a headcount. Nothing in our code knows about hotels or headcounts. We supplied a document and a report and asked for a comparison.

That’s what these three primitives are for. The tools fetch, the resource supplies the rules, the prompt frames the task, and the model does the reasoning in the middle.

Summary

A tool is something the model chooses to call. A resource is something you attach before asking. A prompt is a template you invoke by name. All three are C# methods with attributes, and the SDK discovers them the same way.

The part I’d carry forward is that the Lina fix was a rename and a sentence. No logic changed. When a model uses your tool wrongly, the bug is usually in the English, and the parameter name is part of the English.

In Part 4 the in-memory list goes away. We put the data in Azure SQL with EF Core and call a public API for currency conversion. That’s also where we deal with the fact that search_expense_reports currently returns every line item of every match.

The source code for this tutorial is available on GitHub.

Follow me on X @TJoudeh and on LinkedIn

References:

  • MCP C# SDK, resources
  • MCP C# SDK, prompts
  • MCP specification, resources
  • MCP specification, prompts
  • RFC 6570, URI Template

Share this:

  • Share on X (Opens in new window) X
  • Share on LinkedIn (Opens in new window) LinkedIn
  • Share on Reddit (Opens in new window) Reddit
  • Share on Facebook (Opens in new window) Facebook
  • Email a link to a friend (Opens in new window) Email

Like this:

Like Loading…

Related Posts

  • Part 5: Hosting an MCP Server in ASP.NET Core with Streamable HTTP
  • Part 4: Wiring MCP Tools to Azure SQL and External APIs
  • Part 2: Building Your First MCP Server in C#
  • Part 1: What is the Model Context Protocol? An Introduction for .NET Developers
  • Implementing Dependency Injection using Ninject

Filed Under: AI, MCP Tagged With: .NET, AI Agents, C#, GitHub Copilot, MCP, Model Context Protocol, tool design, VS Code

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.

About Taiseer

Husband, Father, Consultant @ MSFT, Life Time Learner... Read More…

Buy me a coffeeBuy me a coffee

Recent Posts

  • Part 5: Hosting an MCP Server in ASP.NET Core with Streamable HTTP
  • Part 4: Wiring MCP Tools to Azure SQL and External APIs
  • Part 3: MCP Tools, Resources and Prompts in .NET
  • Part 2: Building Your First MCP Server in C#
  • Part 1: What is the Model Context Protocol? An Introduction for .NET Developers

Blog Archives

Recent Posts

  • Part 5: Hosting an MCP Server in ASP.NET Core with Streamable HTTP
  • Part 4: Wiring MCP Tools to Azure SQL and External APIs
  • Part 3: MCP Tools, Resources and Prompts in .NET
  • Part 2: Building Your First MCP Server in C#
  • Part 1: What is the Model Context Protocol? An Introduction for .NET Developers

Tags

.NET AI Agents AJAX AngularJS API API Versioning ASP.NET ASP.NET 6 Authentication Autherization Server Azure Azure Active Directory B2C Azure AD B2C Azure Container Apps Azure Storage C# Code First Dapr Dependency Injection Entity Framework Foursquare API GitHub Copilot jQuery JSON JSON Web Tokens JWT MCP Microservice Model Context Protocol Ninject OAuth OData Redis Resource Server REST RESTful Single Page Applications SPA Token Authentication Tutorial VS Code Web API Web API 2 Web API Security Web Service

Search

Copyright © 2026 Ā· eleven40 Pro Theme on Genesis Framework Ā· WordPress Ā· Log in

%d