Introduction
When adding AI to an existing application, giving the model a database connection string or an endpoint that executes generated SQL creates a second route to business data. That route can bypass the services where application access rules are already enforced.
A read-only database credential is not enough to reproduce application-level permissions. It does not automatically understand tenant boundaries, record-level access, user roles, or business rules.
For ASP.NET Core AI integration, a safer approach is to let the model request an operation, while the application decides how that operation is executed.
This article demonstrates that approach using a customer-support copilot architecture. The assistant uses native tools to retrieve permitted customer information, order status, and company knowledge. Existing .NET services continue to enforce Identity roles and tenant filters.
The key principle is simple:
The model can request an operation, but the application remains responsible for authorization, validation, and execution.
What Native Tool Calling Means
Native tool calling means the model returns a structured request containing a registered function name and its arguments.
The application receives that request, validates it, executes the corresponding operation, and sends the result back to the model.
The model does not execute C# code itself.
For an order-related question, the flow can look like this:
User
↓
AI Model
↓
Tool Call: GetOrderStatus
↓
ASP.NET Core Tool Executor
↓
Existing Order Service
↓
Identity + Tenant Authorization
↓
Database
↓
Tool Result
↓
AI Model
↓
Response DraftFor example, instead of allowing the model to generate SQL such as:
SELECT *
FROM Orders
WHERE Id = 123;the model requests:
GetOrderStatus(orderId = 123)The application then decides whether the request is valid and whether the authenticated user is allowed to access that order.
The model decides that it needs information.
The application decides:
Whether the requested tool exists
Whether the arguments are valid
Who is making the request
Which tenant the request belongs to
Whether the operation is authorized
Which data can be returned
Defining Tools for ASP.NET Core AI Integration
Each tool needs three basic pieces of information:
A tool name
A focused description
A schema describing its arguments
The following .NET 8 examples use simplified service interfaces around an existing ASP.NET Core Identity and EF Core setup.
The imports used across the samples are:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Security.Claims;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;The tool definitions can remain independent of a specific AI provider:
public sealed record ToolDefinition(
string Name,
string Description,
JsonElement Parameters);
public static class ToolCatalog
{
public static readonly ToolDefinition[] All =
[
Define(
"GetCustomer",
"Read a customer by a known customer ID.",
"customerId",
"integer"),
Define(
"GetOrderStatus",
"Read the current status of a known order.",
"orderId",
"integer"),
Define(
"SearchKnowledgeBase",
"Search company policies and procedures.",
"query",
"string")
];
private static ToolDefinition Define(
string name,
string description,
string argument,
string type) =>
new(
name,
description,
JsonSerializer.SerializeToElement(new
{
type = "object",
properties = new Dictionary<string, object>
{
[argument] = new { type }
},
required = new[] { argument },
additionalProperties = false
}));
}A provider adapter can map these definitions into the native function-tool format required by the selected model provider.
For providers that support strict structured outputs, the adapter can enable strict schema validation and ensure that required properties and additionalProperties: false are configured according to the provider's requirements.
The important design principle is that the tool definition describes what the application can retrieve, not how the underlying storage is queried.
The contract can remain stable even if the service implementation changes.
There is no SQL argument, table name, tenant override, or permission flag.
Positive IDs and search-length limits are validated in .NET before execution.
Mapping Tool Calls to Existing Services
The tool executor should use an explicit allowlist.
It should not discover methods through reflection or execute a model-supplied expression.
The service contracts return small DTOs rather than exposing EF Core entities directly.
public sealed record CustomerSummary(
int Id,
string Name);
public sealed record ToolCall(
string Name,
string ArgumentsJson);
public sealed record ToolResult(
bool Success,
object? Data,
string? Error)
{
public static ToolResult Fail(string code) =>
new(false, null, code);
public static ToolResult From(object? value) =>
value is null
? Fail("not_available")
: new(true, value, null);
}
public sealed class ToolExecutor(
ICustomerService customers,
IOrderService orders,
IKnowledgeSearchService knowledge,
ToolArguments arguments,
ILogger<ToolExecutor> logger)
{
public async Task<ToolResult> ExecuteAsync(
ToolCall call,
CancellationToken ct)
{
try
{
return call.Name switch
{
"GetCustomer" => ToolResult.From(
await customers.GetByIdAsync(
arguments.PositiveId(
call.ArgumentsJson,
"customerId"),
ct)),
"GetOrderStatus" => ToolResult.From(
await orders.GetStatusAsync(
arguments.PositiveId(
call.ArgumentsJson,
"orderId"),
ct)),
"SearchKnowledgeBase" => ToolResult.From(
await knowledge.SearchAsync(
arguments.Query(call.ArgumentsJson),
ct)),
_ => ToolResult.Fail("unknown_tool")
};
}
catch (JsonException)
{
return ToolResult.Fail("invalid_arguments");
}
catch (UnauthorizedAccessException)
{
return ToolResult.Fail("not_available");
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogError(
"Tool {Tool} failed: {ErrorType}",
call.Name,
ex.GetType().Name);
return ToolResult.Fail("tool_failed");
}
}
}The executor is an internal application component. It should not be exposed as a public endpoint that accepts arbitrary tool requests from clients.
It uses the same scoped services as the rest of the application.
The returned data should also be deliberately narrow.
For example, an order-status lookup might return:
Order ID
Order Statusrather than an entire EF Core entity with every related property.
Similarly, a knowledge search can return only the relevant passages and their source information.
The service controls the projection. The model cannot request additional database columns or navigation properties.
After execution, the provider adapter serializes ToolResult into the provider's native tool-result format.
Provider-specific correlation information should remain inside the provider adapter. The normalized ToolCall is used for application-level dispatch, while the adapter can retain the provider's original call identifier where required.
Where Permissions Live
User identity and tenant membership should come from the authenticated ASP.NET Core request context.
They should not be supplied by the model.
With ASP.NET Core Identity already configured, a role policy and scoped access helper can be registered:
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<RequestAccess>();
builder.Services.AddAuthorization(options =>
options.AddPolicy(
"Support.Read",
policy => policy
.RequireAuthenticatedUser()
.RequireRole("Admin", "SupportAgent")));Either named role satisfies this policy.
Resource-level access remains the responsibility of the business service.
public sealed class RequestAccess(
IHttpContextAccessor http,
IAuthorizationService authorization)
{
public async Task<Guid> RequireTenantAsync()
{
var user = http.HttpContext?.User;
if (user?.Identity?.IsAuthenticated != true ||
!Guid.TryParse(
user.FindFirst(
ClaimTypes.NameIdentifier)?.Value,
out var userId) ||
userId == Guid.Empty ||
!Guid.TryParse(
user.FindFirst("OrganizationId")?.Value,
out var tenantId) ||
tenantId == Guid.Empty)
{
throw new UnauthorizedAccessException();
}
var allowed = await authorization.AuthorizeAsync(
user,
null,
"Support.Read");
if (!allowed.Succeeded)
{
throw new UnauthorizedAccessException();
}
return tenantId;
}
}The application should establish the organization claim after validating membership.
It should not simply copy tenant information from an untrusted request header.
Missing identity or tenant information should fail closed.
Applying Tenant Filtering in the Existing Service
The customer service can continue to enforce tenant boundaries through its normal EF Core query.
public async Task<CustomerSummary?> GetByIdAsync(
int id,
CancellationToken ct)
{
var tenantId = await access.RequireTenantAsync();
return await db.Customers
.AsNoTracking()
.Where(c =>
c.Id == id &&
c.OrganizationId == tenantId)
.Select(c =>
new CustomerSummary(c.Id, c.Name))
.SingleOrDefaultAsync(ct);
}The result contains only the data required by the assistant.
The order and knowledge services can apply the same authorization approach while retaining their existing access rules.
This is an important architectural boundary:
AI Tool
↓
Existing Service
↓
Authorization
↓
Tenant Filter
↓
Data AccessThe AI layer does not replace the application's existing authorization system.
EF Core global query filters can also be used alongside explicit tenant predicates where appropriate. The AI tool path should not bypass those filters with operations such as IgnoreQueryFilters() unless there is a deliberate, separately authorized reason to do so.
The AI should not receive a privileged service identity.
An administrator's access should still be limited to the tenant and resources allowed for that request.
Knowing a record ID should not change the role policy or tenant predicate.
In production, ticket history and conversation records should also be authorized before they are added to the model context.
Read-Only Tools and State-Changing Actions
The tools shown in this example retrieve information.
A support assistant may also need to prepare actions in the future, but proposing an action and executing an action should be treated differently.
For example, an assistant might eventually propose:
Refund order 123 for $49.99That does not mean the refund should immediately be executed.
A safer workflow is:
AI proposes action
↓
UI displays exact operation
↓
Authorized human reviews it
↓
Human confirms
↓
Server validates approval
↓
Server rechecks authorization
↓
Action executesThe approval should be bound to:
User
Tenant
Resource
Exact operation
Exact payload
At execution time, the server should recheck authorization and current resource state.
It should also reject expired or reused approvals and use idempotency protection where duplicate execution is possible.
A model-generated value such as:
{
"approved": true
}should never be treated as authoritative.
Approval is application state, not another tool argument.
Validating Bad or Hallucinated Arguments
The executor's argument reader should accept exactly the fields expected by each tool.
It should reject:
Extra properties
Duplicate properties
Incorrect data types
Empty queries
Nonpositive IDs
Arguments exceeding configured limits
The limits should come from application configuration rather than from the model.
public sealed record ToolLimits(
int MaxArgumentChars,
int MaxQueryChars);
public sealed class ToolArguments(
ToolLimits limits)
{
private JsonElement ReadSingle(
string json,
string name)
{
if (string.IsNullOrWhiteSpace(json) ||
json.Length > limits.MaxArgumentChars)
{
throw new JsonException();
}
using var document =
JsonDocument.Parse(json);
if (document.RootElement.ValueKind !=
JsonValueKind.Object)
{
throw new JsonException();
}
var fields = document.RootElement
.EnumerateObject()
.ToArray();
if (fields.Length != 1 ||
fields[0].Name != name)
{
throw new JsonException();
}
return fields[0].Value.Clone();
}
public int PositiveId(
string json,
string name)
{
var value = ReadSingle(json, name);
if (value.ValueKind != JsonValueKind.Number ||
!value.TryGetInt32(out var id) ||
id <= 0)
{
throw new JsonException();
}
return id;
}
public string Query(string json)
{
var value = ReadSingle(json, "query");
if (value.ValueKind != JsonValueKind.String)
{
throw new JsonException();
}
var query = value.GetString()!.Trim();
if (query.Length == 0 ||
query.Length > limits.MaxQueryChars)
{
throw new JsonException();
}
return query;
}
}Configured limits should be validated at application startup.
The argument reader and executor can then be registered as scoped services.
Provider response limits should also be enforced at the HTTP boundary before unnecessarily large responses are buffered.
The executor can return different internal error codes:
unknown_tool
invalid_arguments
not_available
tool_failedA missing or inaccessible record can return not_available, avoiding unnecessary cross-tenant existence signals.
An empty knowledge-base search can remain a successful operation with zero matches.
Errors returned to the model should not contain:
SQL statements
Stack traces
Connection information
Raw exception messages
Internal infrastructure details
Cancellation should propagate rather than being reported as a successful lookup.
For not_available and tool_failed, the assistant should acknowledge that no usable information was retrieved.
A failed tool execution is not evidence of an order status, and the assistant should not invent a result.
Important Lessons and Production Considerations
Tool Selection Still Needs Evaluation
Native tool calling does not guarantee that the model will select the correct tool or record.
Both hosted and local models should be tested against scenarios such as:
Ambiguous requests
Incorrect IDs
Missing information
Unauthorized resources
Similar customer names
Invalid arguments
Empty search results
Schema validity and correct intent are separate concerns.
The executor should also be tested independently of the model by replacing the AI provider with deterministic test inputs.
Integration tests should verify that the actual application services correctly deny cross-tenant access.
The Execution Loop Needs Boundaries
Each additional model round trip increases latency and resource consumption.
A production implementation should place limits on:
Maximum tool rounds
Maximum tool calls
Total execution time
Individual tool timeouts
Result size
Prompt/context size
When multiple tools use the same scoped EF Core DbContext, tool operations should be executed sequentially because EF Core does not support concurrent operations on the same DbContext instance.
Retrieved Facts Still Need Careful Presentation
A correctly authorized service response can still result in an inaccurate generated answer.
Knowledge-base results should retain their source information where possible.
Retrieved documents should be treated as data, not as instructions that can change the tool-execution policy.
This is especially important because documents and database content can contain prompt-injection instructions.
The application's authorization boundary must remain outside the model.
For a customer-support assistant, human review can remain the final step before a response is sent to the customer.
Recommended Architecture
The complete architecture can be summarized as:
User
│
▼
ASP.NET Core API
│
▼
AI Provider
│
Native Tool Call
│
▼
Tool Executor
│
┌─────────┼─────────┐
▼ ▼ ▼
Customer Order Knowledge
Service Service Service
│ │ │
└─────────┼─────────┘
▼
Identity + Authorization
│
▼
Tenant Filtering
│
▼
EF Core / Data
│
▼
Tool Result
│
▼
AI Provider
│
▼
Response Draft
│
▼
Human ReviewThe important boundary is that the AI model never becomes a direct database client.
Conclusion
A secure approach to ASP.NET Core AI integration is to let the model choose among approved operations while keeping validation, identity, authorization, tenant scope, and execution inside the application.
Native tool calling provides the connection between conversational requests and existing .NET services without exposing a database interface to the model.
The architecture can be summarized in four principles:
Expose operations, not database access.
Reuse existing ASP.NET Core authorization and tenant boundaries.
Validate every model-generated argument in application code.
Keep state-changing operations behind explicit application-controlled approval.
This approach allows AI capabilities to be added to an existing application without creating a parallel security model.
The model can determine what information it needs, but the application remains responsible for deciding whether that information can be accessed and exactly what is returned.

Join the conversation! Your thoughts help the community grow.