Build Production-Ready AI Agent Tools in C# | Day 11
Our AI agent can now use real application capabilities.
In Day 10, we gave it tools such as:
get_product_stock
search_knowledgeThat allowed the agent to combine structured business data with document knowledge and decide which capability was needed to complete a user goal.
But giving an agent a C# method is only the beginning.
In a real application, every tool should answer important questions:
Who is allowed to call it?
What inputs are valid?
What should it return?
How should failures be represented?
Can it modify business data?
Does the action require approval?
How do we monitor its execution?A poorly designed tool can make even a capable model unreliable.
A well-designed tool gives the agent a narrow, predictable, and secure way to interact with your application.
In Day 11, we will turn our basic agent tools into production-ready C# capabilities.
Our architecture will evolve toward:
User Goal
↓
AI Agent
↓
Tool Selection
↓
Tool Contract
↓
Validation
↓
Authorization
↓
Application Service
↓
Execution
↓
Structured Result
↓
AgentWe will cover:
function tool design
AIFunctionFactorytool names and descriptions
input validation
authorization and tenant isolation
structured results
predictable errors
read vs write tools
human approval boundaries
idempotency
observability
tool versioning
testing
API-backed tools
MCP tools
agent composition
By the end, our agent will not simply have more tools.
It will have better tools.
Previously in This Series
We are building one practical AI application with C# and .NET.
Day 1: Build Your First AI Application
Connected C# to an AI model.
Day 2: Understanding IChatClient
Introduced the common .NET chat abstraction.
Day 3: Build a Reusable AI Chat Service
Added ASP.NET Core and dependency injection.
Day 4: Conversation Memory
Added multi-turn conversations.
Day 5: Streaming
Streamed AI responses.
Day 6: Structured Output
Returned strongly typed C# results.
Day 7: Function Calling
Allowed the model to request C# functions.
Day 8: AI + Database
Connected tools to EF Core and real business data.
Day 9: RAG
Retrieved document knowledge using vector search.
Day 10: AI Agent
Combined multiple capabilities through Microsoft Agent Framework.
Today we focus on the boundary between the agent and our application:
Agent ToolsA Tool Is an Application Contract
It is easy to think of a tool as:
Method the AI can callA better definition is:
A tool is a controlled application contract exposed to an AI agent.
Suppose our agent needs current stock information.
We could expose:
Task<ProductStockInfo?>
GetProductStockAsync(
string productName);But the actual architecture should remain:
Agent
↓
Tool Contract
↓
Application Service
↓
Business Rules
↓
EF Core
↓
DatabaseThe tool should not become a shortcut around the rest of the application.
A production-quality tool should usually be:
Focused
Predictable
Validated
Authorized
Bounded
ObservableFor example:
get_product_stockis focused.
This is not:
execute_business_operationNarrow capabilities are easier for the model to select and easier for developers to secure, test, and monitor.
Function Tools in Microsoft Agent Framework
Microsoft Agent Framework can expose custom application code as function tools.
A .NET method can be converted into an AIFunction using:
AIFunctionFactory.Create(...)For example:
using Microsoft.Extensions.AI;
AIFunction tool =
AIFunctionFactory.Create(
GetProductStockAsync,
name: "get_product_stock",
description:
"Gets the current stock and reorder level for a product.");Conceptually:
C# Method
↓
AIFunctionFactory
↓
AIFunction
↓
Tool Schema
↓
AI ModelThe model can use the resulting metadata to understand the tool's name, purpose, parameters, and expected types.
However, schema generation does not replace application validation.
The model remains an untrusted caller.
Step 1: Start with a Clear Tool Contract
Instead of exposing a generic method:
public Task<object> ExecuteAsync(
string action,
object data)prefer:
public Task<ProductStockToolResult>
GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default)The agent does not need to understand:
Tables
Joins
SQL
DbContext
Connection StringsIt only needs a business capability:
Get the current stock information
for this product.Step 2: Write Clear Tool Descriptions
Tool descriptions help the model decide when a capability should be used.
Create:
AI/InventoryAgentTools.csThen:
using System.ComponentModel;
public sealed class InventoryAgentTools
{
private readonly IInventoryService
_inventoryService;
public InventoryAgentTools(
IInventoryService inventoryService)
{
_inventoryService =
inventoryService;
}
[Description(
"Gets current inventory information for a product, including available quantity and reorder level.")]
public async Task<ProductStockToolResult>
GetProductStockAsync(
[Description(
"The product name to search for.")]
string productName,
CancellationToken cancellationToken = default)
{
// Implementation comes next.
throw new NotImplementedException();
}
}Prefer specific names such as:
get_product_stock
search_knowledge
get_shipping_statusinstead of generic names such as:
get_data
search
executeA tool's name and description should clearly communicate one capability.
Step 3: Validate Tool Inputs
Model-generated arguments should be treated like API input.
For example:
private static string ValidateProductName(
string productName)
{
if (string.IsNullOrWhiteSpace(productName))
{
throw new ArgumentException(
"Product name is required.",
nameof(productName));
}
string value =
productName.Trim();
if (value.Length > 200)
{
throw new ArgumentException(
"Product name cannot exceed 200 characters.",
nameof(productName));
}
return value;
}Then:
public async Task<ProductStockToolResult>
GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default)
{
productName =
ValidateProductName(productName);
// Continue with application service.
}Tool schemas help the model construct arguments.
Validation protects the application.
Step 4: Return Structured Results
Avoid returning loosely formatted text such as:
Mouse stock is 4 and reorder is 10.Instead:
public sealed record ProductStockToolResult(
bool Found,
Guid? ProductId,
string? ProductName,
decimal? AvailableQuantity,
decimal? ReorderLevel,
bool? IsBelowReorderLevel,
string? Message);Implement the tool:
public async Task<ProductStockToolResult>
GetProductStockAsync(
string productName,
CancellationToken cancellationToken = default)
{
productName =
ValidateProductName(productName);
ProductStockInfo? product =
await _inventoryService
.GetProductStockAsync(
productName,
cancellationToken);
if (product is null)
{
return new ProductStockToolResult(
Found: false,
ProductId: null,
ProductName: null,
AvailableQuantity: null,
ReorderLevel: null,
IsBelowReorderLevel: null,
Message:
"Product was not found.");
}
bool isBelowReorderLevel =
product.StockQuantity <
product.ReorderLevel;
return new ProductStockToolResult(
Found: true,
ProductId: product.Id,
ProductName: product.Name,
AvailableQuantity:
product.StockQuantity,
ReorderLevel:
product.ReorderLevel,
IsBelowReorderLevel:
isBelowReorderLevel,
Message: null);
}The agent now receives predictable data:
{
"found": true,
"productId": "a94d...",
"productName": "Wireless Mouse",
"availableQuantity": 4,
"reorderLevel": 10,
"isBelowReorderLevel": true,
"message": null
}This is easier to reason about than application-generated prose.
Keep Deterministic Logic in C#
Notice:
bool isBelowReorderLevel =
product.StockQuantity <
product.ReorderLevel;The model could compare the two numbers, but this is deterministic business logic.
C# should calculate it.
The model can explain the result.
The application should determine the fact.
Let C# calculate facts. Let the model explain them.
This improves consistency and testability.
Step 5: Keep Trusted Context Out of Tool Arguments
Suppose our inventory system is multi-tenant.
Avoid:
GetProductStockAsync(
Guid clientId,
string productName)because ClientId should not be chosen by the model.
Instead:
GetProductStockAsync(
string productName)and obtain the client from trusted application context:
public interface ICurrentClientContext
{
Guid ClientId { get; }
}Then:
public sealed class InventoryAgentTools
{
private readonly IInventoryService
_inventoryService;
private readonly ICurrentClientContext
_clientContext;
public InventoryAgentTools(
IInventoryService inventoryService,
ICurrentClientContext clientContext)
{
_inventoryService =
inventoryService;
_clientContext =
clientContext;
}
}The secure flow becomes:
Authenticated Request
↓
Current Client Context
↓
Agent Tool
↓
Inventory Service
↓
Client-Filtered QueryThe same principle applies to:
UserId
OrganizationId
Role
Permission
Security ScopeTrusted identity should come from application state, not model arguments.
Step 6: Enforce Authorization in C#
Agent instructions may guide behavior, but they are not a security boundary.
Suppose:
public interface IAgentAuthorizationService
{
Task<bool> CanReadInventoryAsync(
CancellationToken cancellationToken = default);
}The tool can enforce:
if (!await _authorizationService
.CanReadInventoryAsync(
cancellationToken))
{
return new ProductStockToolResult(
Found: false,
ProductId: null,
ProductName: null,
AvailableQuantity: null,
ReorderLevel: null,
IsBelowReorderLevel: null,
Message:
"You are not authorized to access inventory information.");
}The distinction is simple:
Prompt
=
Behavior Guidance
Application Code
=
Security EnforcementAuthorization belongs in application code.
Step 7: Return Predictable Errors
Infrastructure exceptions may expose information the model does not need.
Instead of returning:
NpgsqlException...
Host=...
Username=...
Connection refused...use a controlled result:
public sealed record AgentToolResult<T>(
bool Success,
T? Data,
string? ErrorCode,
string? Message);A successful result might be:
{
"success": true,
"data": {
"productName": "Wireless Mouse",
"availableQuantity": 4
},
"errorCode": null,
"message": null
}A controlled failure:
{
"success": false,
"data": null,
"errorCode": "inventory_unavailable",
"message": "Inventory information is temporarily unavailable."
}Internally, log the technical exception.
The agent receives only the information it needs.
Also distinguish expected business outcomes from exceptions.
Examples:
Product not found
Order already cancelled
No relevant policy found
Insufficient stockThese are usually normal application states and should be represented explicitly.
Step 8: Separate Read Tools from Write Tools
Read tools include:
get_product_stock
search_knowledge
get_order_statusWrite tools include:
create_purchase_order
update_product_price
cancel_order
issue_refundThe risk is different.
A read tool typically follows:
Agent
↓
Validation
↓
Authorization
↓
Retrieve Data
↓
Return ResultA write tool may require:
Agent
↓
Validation
↓
Authorization
↓
Business Rules
↓
Approval
↓
Transaction
↓
Audit
↓
Return ResultWrite operations deserve stronger controls.
Step 9: Separate Recommendation from Execution
Suppose low stock may require a purchase order.
Instead of immediately exposing:
create_purchase_orderstart with:
prepare_purchase_orderFor example:
public sealed record PurchaseOrderProposal(
Guid ProductId,
string ProductName,
decimal CurrentStock,
decimal SuggestedQuantity,
string Reason);The result might be:
{
"productId": "a94d...",
"productName": "Wireless Mouse",
"currentStock": 4,
"suggestedQuantity": 50,
"reason": "Current stock is below the configured reorder level."
}At this point:
No purchase order has been created.The architecture becomes:
Recommendation
↓
Approval
↓
ExecutionFor high-impact operations such as:
Create Purchase Order
Issue Refund
Cancel Order
Send Payment
Change Price
Publish Contenthuman approval may be appropriate.
Application authorization and validation must still run even after approval.
Step 10: Make Important Write Tools Idempotent
Suppose:
create_purchase_ordersucceeds, but a timeout prevents the result from reaching the agent.
The agent retries.
Without protection:
PO #1001 created
PO #1002 createdFor side-effecting operations, consider an idempotency key:
CreatePurchaseOrderAsync(
PurchaseOrderRequest request,
string idempotencyKey)The application checks whether the same operation was already processed.
Request
↓
Idempotency Key
↓
Already Processed?
├── Yes → Return Existing Result
└── No → ExecuteThis is particularly important for:
Payments
Orders
Refunds
Bookings
Notifications
External API actionsStep 11: Keep Tool Results Small and Bounded
Do not return an entire entity graph when the agent only needs stock information.
Avoid unnecessarily returning:
Product
Supplier
Category
Stock History
Sales History
Purchase History
Audit HistoryUse a focused projection instead.
Benefits include:
Less sensitive data
Fewer tokens
Lower latency
Clearer reasoning
Smaller attack surfaceCollection tools should also have limits.
For example:
int limit =
Math.Clamp(
requestedLimit,
1,
20);Return the top relevant matches rather than thousands of records.
Step 12: Expose Business Capabilities, Not Infrastructure
Avoid tools such as:
execute_sql
call_any_urlInstead expose:
get_product_stock
find_low_stock_products
get_order_status
get_shipping_statusThe application remains responsible for:
SQL
Endpoints
Authentication
Headers
Timeouts
Retries
Response ProjectionFor database access:
Agent
↓
Business Tool
↓
Application Service
↓
EF Core
↓
DatabaseFor an external API:
Agent
↓
Shipping Tool
↓
Shipping Service
↓
Approved External APIThe model expresses business intent without receiving unrestricted infrastructure access.
Step 13: Create an API-Backed Tool
Suppose we have:
public interface IShippingService
{
Task<ShipmentStatus?>
GetStatusAsync(
string trackingNumber,
CancellationToken cancellationToken = default);
}Create:
public sealed class ShippingAgentTools
{
private readonly IShippingService
_shippingService;
public ShippingAgentTools(
IShippingService shippingService)
{
_shippingService =
shippingService;
}
[Description(
"Gets the current shipping status for an approved tracking number.")]
public async Task<ShipmentStatus?>
GetShippingStatusAsync(
[Description(
"The shipment tracking number.")]
string trackingNumber,
CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(
trackingNumber))
{
throw new ArgumentException(
"Tracking number is required.",
nameof(trackingNumber));
}
return await _shippingService
.GetStatusAsync(
trackingNumber.Trim(),
cancellationToken);
}
}API credentials remain inside the application service.
They never become tool parameters.
Step 14: Use Dependency Injection
Register the tool classes:
builder.Services
.AddScoped<InventoryAgentTools>();
builder.Services
.AddScoped<KnowledgeAgentTools>();
builder.Services
.AddScoped<ShippingAgentTools>();They may depend on normal application components:
Application Services
Authorization Services
Current User Context
Tenant Context
HTTP Clients
Repositories
DbContextThose dependencies remain internal.
Only the tool method signature becomes model-visible.
Step 15: Build a Focused Tool Set
Create the functions:
AIFunction getProductStock =
AIFunctionFactory.Create(
inventoryTools.GetProductStockAsync,
name: "get_product_stock",
description:
"Gets current inventory information for a product.");
AIFunction searchKnowledge =
AIFunctionFactory.Create(
knowledgeTools.SearchKnowledgeAsync,
name: "search_knowledge",
description:
"Searches approved internal business documentation.");
AIFunction getShippingStatus =
AIFunctionFactory.Create(
shippingTools.GetShippingStatusAsync,
name: "get_shipping_status",
description:
"Gets the current shipping status for a tracking number.");Then expose only the tools this agent needs:
AIAgent agent =
new ChatClientAgent(
chatClient,
instructions:
"""
You are a business assistant.
Use available tools when verified
business information is required.
Never invent inventory quantities,
company policies, or shipment status.
If a tool cannot provide the required
information, explain the limitation.
""",
tools:
[
getProductStock,
searchKnowledge,
getShippingStatus
]);As the application grows, avoid giving every agent every capability.
For example:
Inventory Agent
├── get_product_stock
├── find_low_stock_products
└── search_inventory_policy
Support Agent
├── get_ticket
├── search_documentation
└── get_shipping_statusStart with the smallest useful capability set.
Step 16: Add Observability
When an agent produces a wrong or slow response, we need to understand what happened.
Useful telemetry may include:
Agent Run ID
Tool Name
Execution Duration
Success / Failure
Error Code
Result Count
Retry Count
Approval StatusAgent latency includes more than model latency:
Model
↓
Tool A
↓
Model
↓
Tool B
↓
ModelMonitor:
Tool execution time
Database query time
External API latency
Vector search latency
Model latency
Total agent durationBut avoid unnecessarily logging:
Passwords
API Keys
Connection Strings
Sensitive Documents
Payment Details
Private Customer DataObservability should explain execution without creating another data-leak risk.
Step 17: Treat Tool Contracts Like Versioned APIs
Agent tools are application contracts.
As your application evolves, those contracts may evolve too.
For example, today:
get_product_stockmay return:
AvailableQuantity
ReorderLevelLater, the business may need:
AvailableQuantity
ReservedQuantity
IncomingQuantity
ReorderLevel
WarehouseAdding information is often straightforward, but changing the meaning of an existing field can affect agent behavior.
For example, imagine:
AvailableQuantityoriginally means:
Physical stock currently in the warehousebut later silently changes to:
Physical stock - reserved stockThe property name has not changed.
Its business meaning has.
An agent that previously relied on that value may now make a different decision.
Treat important tool contracts similarly to APIs:
Stable Tool Name
↓
Stable Input Contract
↓
Stable Output Contract
↓
Stable Business MeaningWhen a breaking change is necessary, consider introducing a new contract instead of silently changing the existing one.
For example:
get_product_stockcould remain stable while a more specialized capability is introduced:
get_product_inventory_availabilityYou do not need to add v1, v2, and v3 to every tool from the beginning.
The important principle is:
Do not silently change a tool contract in a way that changes what the agent believes the tool means.
Before deploying an important tool-contract change:
review the input schema
review the output schema
check whether field meanings changed
update the tool description if necessary
rerun tool-level tests
test important agent workflows
verify authorization and tenant boundaries still apply
This becomes increasingly important when the same capability is consumed by multiple agents, applications, or MCP clients.
Step 18: Test Tools Without the Agent
A tool should be testable as normal C# code.
For example:
[Fact]
public async Task GetProductStockAsync_ReturnsLowStock()
{
// Arrange
// Act
// Assert
}Useful cases include:
Valid product
Unknown product
Empty product name
Unauthorized user
Wrong tenant
Database unavailable
Cancellation
Low stock
Normal stockTest these separately:
Tool Logic
Tool Selection
Final AI ResponseThis makes failures easier to diagnose.
Step 19: When Should You Use MCP?
Our current tools live inside the ASP.NET Core application.
That is often the simplest choice for application-specific business logic.
But suppose the same capabilities need to be consumed by:
Your AI Agent
Another AI Application
Developer Tools
IDE Assistants
Other Agent SystemsThe Model Context Protocol (MCP) can provide a standardized integration boundary.
Conceptually:
AI Application
↓
MCP Client
↓
MCP Server
↓
Tools / ResourcesMicrosoft Agent Framework supports MCP-based tool integrations.
MCP does not replace:
Authentication
Authorization
Validation
Secrets Management
Approval
AuditingIt standardizes how capabilities can be exposed and consumed.
A simple decision guide:
| Requirement | Function Tool | MCP Tool |
|---|---|---|
| Logic inside current application | Excellent | Usually unnecessary |
| Simple C# business service | Excellent | Optional |
| Shared by multiple AI clients | Possible | Strong fit |
| External standardized tool server | Less natural | Strong fit |
| Lowest complexity | Strong fit | More infrastructure |
| Cross-application reuse | Limited | Strong fit |
For our inventory application, GetProductStockAsync can remain a local function tool.
MCP becomes more attractive when that capability needs to be reused outside the application.
Agents Can Also Become Tools
As responsibilities grow, a specialized agent can become a capability used by another agent.
Conceptually:
Coordinator Agent
├── Inventory Agent
├── Support Agent
└── Finance AgentThis can be useful when responsibilities genuinely diverge.
But do not introduce multiple agents merely because the framework supports them.
A single agent with three focused tools is often easier to understand and operate than several unnecessary agents.
Recommended Production Architecture
A scalable design might look like:
AI Agent
↓
Tool Contracts
↓
┌──────────────┼──────────────┐
↓ ↓ ↓
Inventory Knowledge Shipping
Tools Tools Tools
↓ ↓ ↓
Application Retrieval Application
Services Service Service
↓ ↓ ↓
EF Core Vector Store External API
↓
DatabaseAround those capabilities:
Authentication
Authorization
Validation
Tenant Isolation
Approval
Idempotency
Logging
Metrics
CancellationThe agent sees business capabilities.
The application keeps control of security and infrastructure.
Common Agent Tool Mistakes
Generic Tool Contracts
Avoid:
get_data
execute
processPrefer one clearly defined business capability.
Returning Too Much Data
Return only what the agent needs.
Trusting Model Arguments
Validate model-generated values like any other untrusted input.
Passing Trusted Identity Through the Model
Resolve tenant, user, and permissions from application context.
Using Prompts as Authorization
Enforce permissions in C#.
Exposing Infrastructure
Avoid arbitrary SQL, URLs, or system-level capabilities.
Ignoring Write Risk
State-changing tools need stronger controls than read tools.
Ignoring Duplicate Execution
Use idempotency where repeated execution can create duplicate side effects.
Silently Changing Tool Contracts
Treat tool inputs, outputs, and business semantics as application contracts. Test important agent workflows before deploying breaking changes.
Exposing Internal Exceptions
Return controlled errors and keep technical details in internal logs.
Testing Only Through the LLM
Unit test tool behavior independently.
Production Checklist
Before exposing a tool to an AI agent, verify:
the tool has one clear responsibility
its name and description are specific
inputs are validated
model-generated values are treated as untrusted
tenant and user identity come from trusted context
authorization is enforced in application code
deterministic business logic remains in C#
outputs are structured and minimal
collection results are bounded
expected business failures are explicit
infrastructure exceptions are not exposed
write operations have stronger controls
sensitive actions can require approval
duplicate side effects are prevented where necessary
important tool contracts remain stable
breaking contract changes are tested before deployment
cancellation is propagated
execution is observable
logs avoid sensitive data
tools can be tested independently
MCP is introduced only when its reuse benefits justify it
What We Built Today
In Day 10, our architecture was:
User
↓
Agent
↓
Tools
↓
ApplicationToday we strengthened that boundary:
User
↓
Agent
↓
Tool Selection
↓
Validated Contract
↓
Trusted User / Tenant Context
↓
Authorization
↓
Business Logic
↓
Execution
↓
Structured Result
↓
AgentWe also introduced important production concepts:
Read vs Write Tools
Human Approval
Idempotency
Observability
Tool Versioning
Independent Testing
MCP
Agent CompositionThe agent is becoming more capable.
But the application remains in control.
Day 11 Checklist
Before moving to Day 12, make sure you understand:
why a tool is an application contract
how
AIFunctionFactory.Create()exposes C# methodswhy names and descriptions matter
why tool arguments must be validated
why structured results are preferable
why deterministic logic belongs in C#
why trusted identity stays outside model arguments
why prompts cannot replace authorization
how business failures differ from infrastructure failures
why read and write tools have different risk
why high-impact actions may require approval
why write operations may need idempotency
why results should be minimal and bounded
why arbitrary SQL and HTTP tools are risky
how dependency injection supports tools
why tool execution needs observability
why tool contracts should remain stable
why tools should be tested independently
when MCP may be useful
What's Next: Day 12
Our agent now has production-quality capabilities.
But we intentionally stopped at an important boundary:
Agent recommends an action
↓
Should it actually execute it?In Day 12: Add Human Approval to AI Agent Actions in C#, we will build that boundary properly.
We will explore:
Agent
↓
Proposed Action
↓
Approval Required
↓
Human Decision
├── Approve
│ ↓
│ Validate Again
│ ↓
│ Execute
│
└── Reject
↓
Stop ActionWe will focus on high-impact operations such as:
Create Purchase Order
Issue Refund
Cancel Order
Send External MessageThe goal is to let the agent help prepare and coordinate actions without giving the model uncontrolled authority over real business state.
Final Thoughts
The quality of an AI agent depends heavily on the quality of its tools.
A strong production boundary looks like:
AI Reasoning
↓
Narrow Tool Contract
↓
Validation
↓
Authorization
↓
Deterministic Business Logic
↓
Controlled ExecutionThe model should understand intent.
Your application should control authority.
Give the agent capabilities, not unrestricted access.
A good tool tells the agent:
Here is one thing you are allowed to ask
the application to do.It does not say:
Here is the entire system.
Do whatever you think is appropriate.Day 10 taught our agent how to choose capabilities.
Day 11 made those capabilities safer, clearer, version-aware, and more predictable.
In Day 12, we will add human approval before sensitive agent actions are allowed to change real business state.
Comments 0