In the earlier tools example, we gave an agent a C# function to search for meeting rooms. That worked well, but what if an employee should only be able to search rooms in a particular city? We also need a way to see when a run started, which tools were allowed, and whether the run finished successfully.
In this post, we will add middleware to a Microsoft Agent Framework agent. We will keep the meeting-room example and add two callbacks: one for run logging and another for checking tool calls before they execute.
The model can request a room search, but our application decides whether that search is allowed. We won't rely on instructions in the prompt to enforce the employee's permissions.
What we are building
- Use a model in Microsoft Foundry with a local meeting-room search tool.
- Log the start, outcome, and elapsed time of an agent run.
- Check the requested tool and its arguments against application-owned caller context.
- Allow a London search and block a Sydney search for the same caller.
- Return a policy error to the agent without running a denied tool.
Request
-> Run logging: start
-> Agent / Foundry model
-> Tool policy check
-> Allowed: call the C# room-search tool
-> Denied: return a policy error without calling the tool
-> Agent produces the final reply
-> Run logging: outcome and elapsed time
Notice where the policy check runs: after the model requests a function, but before the C# function executes. The run callback wraps the whole operation, including the model's tool-calling loop.
Before you start
- .NET 10 SDK. This example follows the .NET setup used in the previous posts.
- A Microsoft Foundry project and a model deployment that supports function calling.
- An identity permitted to use the project and deployment, and Azure CLI for
az login.
This sample always uses Foundry for inference. There is no offline mode. The room data itself stays in a small in-memory C# directory, so we don't need Microsoft Graph or a database.
1) Create the .NET project
Create a console application and install the packages. In PowerShell:
dotnet new console -n AgentWithMiddleware --framework net10.0
Set-Location AgentWithMiddleware
dotnet add package Microsoft.Agents.AI.Foundry --version 1.24.0-preview.261006.1
dotnet add package Azure.Identity --version 1.21.0
These are the pinned versions used in the sample, matching the preceding post. The Foundry integration is a preview package. The core agent and Microsoft.Extensions.AI tool APIs are brought in transitively.
2) Choose the middleware boundary
Agent Framework supports three middleware boundaries in .NET:
- Agent run middleware: wraps an agent run. We will use this for logging and elapsed time.
- Function calling middleware: wraps locally invoked function tools. We will use this to authorize the room search.
- IChatClient middleware: wraps individual inference calls. We don't need it for this example.
A single agent run can make several model calls and invoke several tools. That is why run logging and function authorization belong at different boundaries. We want to record the whole run, but check each requested function invocation independently.
Function middleware requires a FunctionInvokingChatClient in the inner pipeline, as used by the Foundry-created chat-client agent here. It isn't an interceptor for arbitrary server-side tools.
3) Log the agent run
Our run callback receives the messages, session, options, inner agent, and cancellation token. Calling innerAgent.RunAsync continues the pipeline. We forward all of those values unchanged:
public async Task<AgentResponse> LogRunAsync(
IEnumerable<ChatMessage> messages,
AgentSession? session,
AgentRunOptions? options,
AIAgent innerAgent,
CancellationToken cancellationToken)
{
Stopwatch stopwatch = Stopwatch.StartNew();
string outcome = "completed";
string? errorType = null;
Audit("run.started");
try
{
return await innerAgent.RunAsync(messages, session, options, cancellationToken);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
outcome = "cancelled";
throw;
}
catch (Exception ex)
{
outcome = "failed";
errorType = ex.GetType().Name;
throw;
}
finally
{
Audit("run.finished", outcome: outcome, elapsedMs: stopwatch.ElapsedMilliseconds, errorType: errorType);
}
}
The Audit helper writes a JSON line containing a timestamp, correlation ID, caller ID, event, and optional outcome details. It does not log the prompt, tool arguments, tool results, or exception messages. Failures are rethrown instead of being turned into a successful response.
The final event is written in finally, so a model error or cancellation is still recorded. This is a console example, not a durable enterprise audit store; a production application needs an appropriate logging sink and a policy for logging failures.
4) Authorize the function before it runs
We will represent the application caller with a small record. For this example, the employee can search only London:
public sealed record CallerContext(string EmployeeId, string AllowedCity);
public sealed record ToolPolicyError(string Code, string Message);
CallerContext caller = new("employee-123", "London");
MeetingRoomMiddleware middleware = new(caller, Console.Out);
These values are supplied by our code, not by the model. In an enterprise service, we would resolve the authenticated caller and their policy server-side. Do not let the prompt or an untrusted request choose the employee ID or allowed city. The fixed record here illustrates that boundary; it does not implement authentication.
The function callback receives a FunctionInvocationContext and a next delegate. Only calling next(context, cancellationToken) continues to the tool. Our callback checks the tool allowlist, validates its arguments, and compares the city with the caller's policy:
public async ValueTask<object?> AuthorizeToolAsync(
AIAgent agent,
FunctionInvocationContext context,
Func<FunctionInvocationContext, CancellationToken, ValueTask<object?>> next,
CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
if (string.IsNullOrWhiteSpace(caller.EmployeeId) || string.IsNullOrWhiteSpace(caller.AllowedCity))
{
return Deny("MissingCaller", "The application has not supplied an authorized caller.");
}
if (context.Function.Name != ToolName)
{
return Deny("ToolNotAllowed", "This tool is not allowed for this request.");
}
JsonElement arguments = JsonSerializer.SerializeToElement(context.Arguments);
if (arguments.ValueKind != JsonValueKind.Object
|| arguments.EnumerateObject().Count() != 3
|| !arguments.TryGetProperty("city", out JsonElement cityValue)
|| cityValue.ValueKind != JsonValueKind.String
|| string.IsNullOrWhiteSpace(cityValue.GetString())
|| !arguments.TryGetProperty("minimumCapacity", out JsonElement capacityValue)
|| capacityValue.ValueKind != JsonValueKind.Number
|| !capacityValue.TryGetInt32(out int capacity)
|| capacity is < 1 or > 20
|| !arguments.TryGetProperty("requiresTeamsRoom", out JsonElement teamsValue)
|| teamsValue.ValueKind is not (JsonValueKind.True or JsonValueKind.False))
{
return Deny("InvalidArguments", "Supply a city, a capacity from 1 to 20, and a boolean Teams requirement.");
}
string city = cityValue.GetString()!.Trim();
if (!city.Equals(caller.AllowedCity.Trim(), StringComparison.OrdinalIgnoreCase))
{
return Deny("CityNotAllowed", "The caller is not authorized to search rooms in this city.");
}
Audit("tool.allowed", tool: ToolName);
object? result = await next(context, cancellationToken);
Audit("tool.completed", tool: ToolName);
return result;
}
ToolName is the name of our FindAvailableMeetingRooms function. The Deny helper records a tool.denied event and returns a ToolPolicyError. There is no call to next on a denied path, so the directory is never searched.
We require exactly the three tool arguments, including an integer capacity from 1 to 20 and a boolean Teams requirement. The city check trims whitespace and ignores case, using the same normalization as the directory. We don't coerce a string such as "8" into an integer.
A denied call returns an ordinary function result to the model, which can then explain the restriction. This can still be a completed agent run; a completed run does not mean the tool was authorized. We don't set FunctionInvocationContext.Terminate, so the normal function-result flow is preserved.
5) Register the middleware on the agent
The meeting-room tool is the same kind of C# function we used earlier. It filters five sample rooms by city, capacity, availability, and Teams equipment. Only Regent matches the London request for eight people with Teams.
After creating the Foundry-backed agent, use AsBuilder, register both callbacks with Use, and call Build:
AIProjectClient projectClient = new(projectUri, credential);
MeetingRoomDirectory directory = new();
AIAgent originalAgent = projectClient.AsAIAgent(
model: modelDeployment,
instructions: """
You help employees find meeting rooms.
Always call FindAvailableMeetingRooms with the exact city, capacity, and Teams requirement requested.
Only recommend rooms returned by the tool. Never invent room data.
If the tool returns a policy error, explain that the search was not performed.
Do not retry a denied search with a different city or altered arguments.
""",
name: "MeetingRoomAssistant",
tools: [AIFunctionFactory.Create(directory.FindAvailableMeetingRooms)]);
AIAgent agent = originalAgent
.AsBuilder()
.Use(runFunc: middleware.LogRunAsync, runStreamingFunc: null)
.Use(middleware.AuthorizeToolAsync)
.Build();
We run the wrapped agent, not originalAgent. Calling the original agent, or calling the C# directory directly, would bypass these callbacks. Real downstream APIs should enforce their own authorization as well.
The sample uses non-streaming RunAsync. Passing only runFunc means streaming invocations use the non-streaming middleware path. If we need genuine streaming, we should implement an appropriate streaming callback too.
6) Run an allowed search and a denied search
Open PowerShell in the sample directory, set your Foundry settings, and sign in. Use the project endpoint, not an Azure OpenAI endpoint, and your deployment name rather than a model catalog ID:
$env:FOUNDRY_PROJECT_ENDPOINT = "https://<resource>.services.ai.azure.com/api/projects/<project>"
$env:FOUNDRY_MODEL_DEPLOYMENT = "<your-function-calling-deployment>"
az login
dotnet run
The application uses DefaultAzureCredential with managed identity probing disabled for local development. The Azure identity grants access to Foundry; it is separate from the illustrative employee caller used in the room policy. Missing configuration or inference errors stop the sample rather than switch to a fixed response.
The default prompt asks for a London room for eight people with Teams equipment. For an allowed tool call, the event sequence should be run.started, tool.allowed, tool.completed, and run.finished. The final reply should recommend Regent.
Now request Sydney without changing the caller's London policy:
dotnet run -- Sydney
When the model requests that search, look for tool.denied with outcome CityNotAllowed and Meeting-room tool executions: 0. Harbour exists in the directory, but the tool never runs to retrieve it. The following shows an illustrative denied event, abbreviated to the relevant fields:
{"runId":"<run-id>","eventName":"tool.denied","callerId":"employee-123","outcome":"CityNotAllowed"}
Model responses and tool-call counts can vary. The instructions encourage the expected search, but they are not the access-control mechanism. The middleware enforces the policy on each function invocation through the wrapped agent; it does not guarantee that the model will call a tool or that its final text will be factually correct.
A few production considerations
- Keep caller context request-scoped. This sample creates one middleware instance and wrapped agent for one run. Its caller and correlation ID are fixed; don't share it as a singleton across employees or runs.
- Keep authorization in code. Resolve identity and policy server-side, and enforce authorization in downstream systems too. A prompt is not an authentication boundary.
- Handle audit data deliberately. Caller IDs are identity metadata. Use access controls and retention appropriate to the logging sink. The application separately prints the final agent response, so all console output is not a metadata-only audit stream.
- Add tracing separately when needed. OpenTelemetry can correlate agent, model, and tool activity across services. It complements these policy checks; this small example does not configure a telemetry exporter.
This is not a human approval workflow. The authorization decision is made automatically from application-owned policy before the tool runs. An approval step would be a separate capability.
Wrapping up
We used run middleware to record the lifecycle of an agent request and function middleware to check whether the room-search tool could execute. The useful boundary is the call to next: if our policy denies the operation, we return an error result without invoking the function.
The same pattern can be used for other application tools, such as reading a document or creating a booking. Keep the agent focused on the task, and let application code decide which operations the caller is allowed to perform.
Hope this helps!




