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 Draft

For 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:

  1. A tool name

  2. A focused description

  3. 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 Status

rather 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 Access

The 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.99

That 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 executes

The 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_failed

A 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 Review

The 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:

  1. Expose operations, not database access.

  2. Reuse existing ASP.NET Core authorization and tenant boundaries.

  3. Validate every model-generated argument in application code.

  4. 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.