AI Agents

How to Connect a C# AI Agent to SQL Server Safely

Expose a fixed read-only SQL operation with authenticated tenant binding, least privilege, typed parameters, and bounded execution.

Authenticated user passes application tenant policy to a fixed query tool and least-privilege SQL Server identity.

A safe SQL agent tool is a reviewed application operation, not a prompt that asks a model to write careful SQL. The shared Jovenzo.Samples project exposes one read-only operation: the model chooses a product-name prefix and a result count, while trusted application code fixes the tenant, SQL text, view, columns, parameter types, timeout, and result shape.

This tutorial follows the runnable --agent-sql path and its actual SafeProductTool. Offline tests inspect the generated command and Agent Framework schema without opening SQL Server or contacting OpenAI. That evidence establishes the application contract; it does not establish deployed grants, row-level isolation, query performance, or live model behavior.

Start with a constrained operation

The concrete flow is small enough to review end to end: trusted application context constructs SafeProductTool(connectionString, authorizedTenantId); the model sees only find_products(prefix, take); CreateCommand emits fixed SQL and three typed parameters with a five-second command timeout; the reader returns at most twenty ProductSummary rows in ProductId order; the bounded result returns to the agent.

The shared archive is the only runnable implementation. Start in the extracted Jovenzo.Samples

FileRole
src/Jovenzo.Samples.Core/SafeProductTool.csValidation, fixed command, typed parameters, LIKE escaping, five-second timeout, async execution, and typed rows.
src/Jovenzo.Samples.Sdk/Program.csThe real --agent-sql command, 45-second agent deadline, and live client construction after configuration validation.
src/Jovenzo.Samples.Sdk/SqlAgentSample.csShared setting validation, model-visible tool creation, common iteration wrapper, and agent composition.
src/Jovenzo.Samples.Sdk/AgentRuntime.csThe existing four-iteration automatic function-invocation limit.
src/Jovenzo.Samples.Sdk/SdkConfiguration.csRejects missing, empty, and whitespace-only required values.
tests/Jovenzo.Samples.Tests/SafeProductToolTests.csSixteen offline command, parameter, validation, timeout, and escaping cases.
tests/Jovenzo.Samples.Tests/SqlAgentSampleTests.csSeventeen offline configuration, tool-schema, iteration, and disposal cases.
sql/reporting-view.sqlAn unexecuted DBA review script for the narrow view and catalog_reader role.
README.mdPackage commands, boundaries, and verification results.
docs/technical-verification.mdRepository-side evidence report; it is not inside the source ZIP.

A general execute_sql tool would let model output choose syntax, objects, filters, and cost. This sample instead exposes one application-owned question. Its read-only design reduces write risk, but selected rows can still be sensitive and repeated reads can still exhaust capacity. Authorization, least-privilege database permissions, result bounds, elapsed-time limits, and production concurrency policy remain separate responsibilities.

Read-only database access is restricted by approved columns, tenant rows, and resource budgets.
Confidentiality and availability need controls beyond preventing writes.

Create a narrow database access surface

The package's sql/reporting-view.sql is an illustrative review artifact for a disposable database. It creates a reporting schema, exposes only ProductId, TenantId, Name, and UnitPrice, and grants one role access to that view:

CREATE VIEW reporting.ProductCatalog
AS
    SELECT ProductId, TenantId, Name, UnitPrice
    FROM dbo.Products;
GO
CREATE ROLE catalog_reader;
GRANT SELECT ON OBJECT::reporting.ProductCatalog TO catalog_reader;
GO
-- Do not grant db_datareader, db_owner or direct base-table access.

The role name and GRANT match the actual script: the role is catalog_reader, not ai_catalog_reader. The Database Engine permissions guidance explains that effective authority depends on principals, role membership, grant scope, ownership, and other permissions. The script was not applied during verification, so no claim is made that the role exists, writes are denied, or an application identity has only the intended grant.

The view is a column boundary, not tenant isolation. It contains TenantId so application SQL can filter, but a principal with SELECT on the view could query other tenants unless another database policy prevents it. SQL Server row-level security can be considered as production defense in depth, with tests under the real runtime principal and connection-pooling behavior. RLS is not implemented by this sample.

Bind tenant identity from trusted application context

Parameterized SQL and authorization solve different problems. Parameters keep untrusted values separate from SQL syntax. They do not decide which tenant a caller may access, grant a database permission, or prove row-level isolation. The constructor-bound authorizedTenantId must therefore come from trusted application policy:

public sealed class SafeProductTool(
    string connectionString,
    Guid authorizedTenantId)
{
    // Tenant identity is supplied by authenticated application code,
    // never by an LLM argument.
}

For the tutorial command, SAMPLE_AUTHORIZED_TENANT is selected by the trusted local operator and validated as a nonempty GUID before the live clients are constructed. That convention makes a local demonstration reproducible; it is not production user authentication. A public service must derive the tenant from authenticated membership and authorization policy, not a request body, chat message, or model argument.

The generated Agent Framework schema was inspected offline. Its name is find_products and its only model-visible properties are prefix and take. The connection string, tenant, SQL text, view name, sort expression, timeout, database credentials, and method cancellation token are absent. This follows the actual bound FindAsync(string prefix, int take, CancellationToken) signature; the token is supplied by the invocation pipeline rather than by the model.

Server supplies authorized tenant while the model supplies only a prefix and count to typed SQL parameters.
The tool instance receives tenant authority from trusted application code.

Build a fixed, typed, bounded SQL command

CreateCommand is the public SQL contract. It accepts a nonblank prefix of at most 80 characters, a count from 1 through 20, and a nonempty constructor tenant. The command text fixes the view, selected columns, tenant predicate, literal-prefix semantics, and ordering:

public SqlCommand CreateCommand(
    SqlConnection connection, string prefix, int take)
{
    ArgumentException.ThrowIfNullOrWhiteSpace(prefix);
    if (prefix.Length > 80)
        throw new ArgumentException(
            "Prefix must be at most 80 characters.", nameof(prefix));
    if (take is < 1 or > 20)
        throw new ArgumentOutOfRangeException(nameof(take));
    if (authorizedTenantId == Guid.Empty)
        throw new InvalidOperationException(
            "An authenticated tenant is required.");

    const string sql = """
        SELECT TOP (@take) ProductId, Name, UnitPrice
        FROM reporting.ProductCatalog
        WHERE TenantId = @tenant AND Name LIKE @prefix ESCAPE '\'
        ORDER BY ProductId;
        """;
    var command = new SqlCommand(sql, connection)
        { CommandTimeout = 5 };
    command.Parameters.Add("@take", SqlDbType.Int).Value = take;
    command.Parameters.Add("@tenant", SqlDbType.UniqueIdentifier).Value =
        authorizedTenantId;
    command.Parameters.Add("@prefix", SqlDbType.NVarChar, 161).Value =
        EscapeLike(prefix) + "%";
    return command;
}

The SqlClient parameter guidance explains the value/syntax separation. For the input x'; DROP TABLE Products; --, the SQL text does not change. The complete string becomes the value of @prefix, followed by the final prefix wildcard. It was not an attack “blocked by SQL Server”; application code never turned it into SQL syntax.

LIKE metacharacters need an additional semantic step. Percent, underscore, opening bracket, and backslash are escaped before the final % is appended. In the worst case, all 80 prefix characters expand to two characters, then the wildcard adds one, which is why @prefix is NVarChar(161). Explicit tests cover each metacharacter and a combined value without copying the production escaping algorithm into the test suite.

The approved-command test compares normalized SQL tokens rather than checking only that “DROP” is absent. It fails if the reporting view, tenant predicate, ESCAPE clause, ordered subset, or parameterized TOP (@take) changes. Separate assertions fix all three parameter types and values plus the five-second timeout.

Execute asynchronously and return a small result

The execution method uses the same cancellation token for connection opening, command execution, and every reader advance. It maps only the three approved columns into ProductSummary:

public async Task<IReadOnlyList<ProductSummary>> FindAsync(
    string prefix, int take, CancellationToken cancellationToken)
{
    await using var connection = new SqlConnection(connectionString);
    await using var command = CreateCommand(connection, prefix, take);
    await connection.OpenAsync(cancellationToken);
    await using var reader =
        await command.ExecuteReaderAsync(cancellationToken);
    var results = new List<ProductSummary>();
    while (await reader.ReadAsync(cancellationToken))
        results.Add(new ProductSummary(
            reader.GetInt32(0), reader.GetString(1), reader.GetDecimal(2)));
    return results;
}

Microsoft's ExecuteReaderAsync documentation says the token can request abandonment before the command timeout elapses. That is cooperative cancellation, not a promise of immediate server-side termination or transaction rollback. This sample performs only a SELECT; a future write operation would need its own transaction, idempotency, and outcome-reconciliation design.

The five-second CommandTimeout applies to command execution and result network reads. It does not universally bound connection opening, authenticate a caller, prove that the query is indexed, or replace the 45-second agent deadline. Current SqlClient guidance treats connection timeout, command timeout, and cancellation as distinct controls.

SQL_CONNECTION_STRING remains outside source control. Production guidance can require encryption, certificate validation, and a least-privilege identity, but an arbitrary environment value does not prove those properties. The sample and config.example.json do not ship a connection string or recommend TrustServerCertificate=true.

SQL tool validates lengths and count, parameterizes values, then executes with cancellation and timeout.
Literal LIKE escaping preserves prefix semantics in addition to injection protection.

Connect the bounded operation to the agent

The live command reads and validates all four required values before constructing the OpenAI chat client. SqlAgentSample then creates the same find_products function tested offline and applies the shared AgentRuntime wrapper:

var settings = SqlAgentSample.ReadSettings();
var tool = new SafeProductTool(
    settings.ConnectionString, settings.AuthorizedTenantId);

using IChatClient chat = SqlAgentSample.CreateOwnedBoundedClient(
    new OpenAIClient(settings.ApiKey)
        .GetChatClient(settings.Model)
        .AsIChatClient());
AIAgent agent = SqlAgentSample.CreateAgent(chat, tool);

Console.WriteLine(
    "Local operator-selected tenant configuration is a teaching " +
    "fixture, not production user authentication.");
using var deadline =
    new CancellationTokenSource(TimeSpan.FromSeconds(45));
Console.WriteLine(await agent.RunAsync(
    "Find up to 5 products whose names begin with key.",
    cancellationToken: deadline.Token));

The wrapper sets MaximumIterationsPerRequest = 4, the same tested value used by the first-agent sample. It limits automatic function-invocation round trips; it is not a four-query database quota. One model response could request multiple calls, and one tool call still performs one database operation. A public service also needs per-request or per-user call policy, database concurrency limits, and capacity planning.

Ownership is singular: the program disposes the bounded wrapper, and the wrapper disposes its inner live chat client. An offline regression observes that ownership without creating an OpenAI client. The 45-second token bounds the overall agent run cooperatively; the SQL command retains its separate five-second setting.

To run the live path from the extracted sample root, set SQL_CONNECTION_STRING, SAMPLE_AUTHORIZED_TENANT, OPENAI_API_KEY, and OPENAI_MODEL, then use:

dotnet run --project src/Jovenzo.Samples.Sdk `
  --configuration Release --no-build -- --agent-sql

This command can contact SQL Server and OpenAI and can incur provider usage. It was not executed for this revision because no explicitly authorized disposable database and provider environment were supplied. Missing credentials are reported as missing configuration; no output was fabricated.

The Agent Framework tutorial covers the shared agent loop. The MCP tutorial covers a separate local protocol boundary; --agent-sql calls the SQL tool directly and does not route database credentials through MCP.

Test the command and the database separately

After locked restore and the four-project Release build, run the two focused suites without keys or a database:

dotnet test tests/Jovenzo.Samples.Tests/Jovenzo.Samples.Tests.csproj `
  --configuration Release --no-build --no-restore `
  --filter 'FullyQualifiedName~SafeProductToolTests'

dotnet test tests/Jovenzo.Samples.Tests/Jovenzo.Samples.Tests.csproj `
  --configuration Release --no-build --no-restore `
  --filter 'FullyQualifiedName~SqlAgentSampleTests'

The command suite covers null, empty, whitespace, 80- and 81-character prefixes; take values 0, 1, 20, and 21; empty and nonempty tenants; the complete approved SQL shape; hostile input isolation; exact parameter metadata and values; the timeout; and five explicit LIKE-escaping examples. It constructs SqlCommand objects but never opens their connections.

The composition suite covers missing, empty, and whitespace connection/key/model settings; missing, malformed, whitespace, and empty-GUID tenants; a valid settings record; the exact two-property function schema; the shared four-iteration wrapper; and disposal ownership. It does not duplicate SafeProductTool behavior or instantiate a live provider.

A disposable SQL integration environment would answer different questions: whether the script executes, role membership and grants are effective, writes and unrelated objects are denied, application and database tenant defenses agree, indexes support the query, and timeouts behave under real load. None of those were tested here.

The shared source archive contains the tool, composition, tests, and review script. The ZIP was extracted into a separate directory and the documented restore/build/test path was repeated; generated bin, obj, test results, provider output, and transcripts are not stored in the archive.

Offline command inspection is separate from database principal verification and agent answer evaluation.
Live grants, tenant isolation, and query performance require a real integration environment.

Control resource use and generated interpretation

The implemented numbers form a specific policy rather than a general claim of production readiness:

BoundaryImplemented valueWhat it protects
Prefix1–80 charactersRejects empty or oversized search input before database work.
Result count1–20 rowsBounds one returned subset; it is not a per-user quota.
Prefix parameterNVarChar(161)Fits worst-case escaping plus the final prefix wildcard.
OrderingORDER BY ProductIdMakes the bounded subset deterministic for this operation.
SQL command5 secondsBounds command/result network waiting, not every connection-open scenario.
Function loop4 iterationsBounds framework automatic round trips, not database operations globally.
Agent run45 secondsProvides a cooperative outer deadline across the agent run.
Selected dataProductId, Name, UnitPriceReturns only fields needed by the model; TenantId is used for filtering but not mapped into results.

The sample does not implement a per-user quota, database concurrency limiter, query-cost governor, RLS policy, or production authentication flow. Those are explicit extensions. A bounded SQL string is still capable of poor performance if the data, collation, statistics, or indexes do not support the prefix query.

The agent should describe twenty rows as a bounded subset and an empty list as no matching approved records. A timeout, denied permission, canceled operation, and empty result are distinct outcomes. Production error mapping should preserve that distinction internally without exposing connection strings, raw SQL diagnostics, or sensitive row values to the model.

Keep writes out of the first release

This tutorial operation is a SELECT and the review script grants only SELECT on one view. Adding inserts, updates, deletes, approvals, or transactions would change the risk model. A write tool needs explicit user intent, fine-grained authorization, idempotency, audit records, concurrency handling, and reconciliation when a client disconnects after the server commits.

The 45-second cancellation deadline cannot undo a committed external write. The current read-only operation avoids that ambiguity and keeps the tutorial focused on tenant binding, fixed SQL, typed values, and bounded results.

Verification results — November 2, 2026

A. Build and package. Locked restore succeeded; Core, Api, Sdk, and Tests built in Release with 0 warnings and 0 errors in the source tree and a separate ZIP extraction. The shared download retained its path and four-project root.

B. SQL command and validation. The SafeProductToolTests subset passed 16/16. It used disconnected commands only. No SQL Server was contacted.

C. Agent and configuration. The SqlAgentSampleTests subset passed 17/17, and the existing Agent Framework subset passed 13/13. The full C# suite passed 275/275 with 0 failures or skips. Focused counts are included in 275, not added to it.

D. Shared offline regressions. All 45 JavaScript tests passed. --agent-offline, --mcp-smoke, and --agent-mcp-offline completed in both source and extracted copies. These paths made no SQL or OpenAI request.

E. Explicitly unverified. The SQL script was not applied; connectivity, effective grants, denied writes, database-level cross-tenant protection, RLS, indexes and query plans, real timeout/cancellation behavior, production performance, and live --agent-sql output remain unverified. No provider or database result is presented.

Key takeaways

  • The model chooses only prefix and take; trusted application code supplies tenant and credentials.
  • Fixed SQL and typed parameters protect syntax boundaries but do not authorize tenants or prove database isolation.
  • The view exposes four reviewed columns, while the tool returns three bounded summary fields.
  • Five-second SQL timeout, four function iterations, and a 45-second agent deadline are different controls.
  • Offline command/schema tests and live database/provider integration establish different evidence.

Frequently asked questions

Does parameterization make the tool tenant-safe?

No. It prevents parameter values from becoming SQL syntax. Tenant safety begins with authenticated membership policy, continues through constructor binding and the tenant predicate, and may add database-enforced isolation. This tutorial proves only the offline application command.

Why does the view contain TenantId if the result omits it?

The command needs TenantId for its fixed predicate. The model-facing ProductSummary needs only ProductId, Name, and UnitPrice. Keeping the filter column in the view does not grant the model authority to select a tenant.

Does CommandTimeout include connection opening?

Not as a universal bound. Command timeout, connection timeout, cancellation, and the outer agent deadline cover different stages. Their production behavior must be tested against the actual deployment.

Can I enable the live command immediately?

Only after supplying your own authorized OpenAI and disposable SQL environments, reviewing the script and identity, and accepting possible provider usage. The offline tests do not certify a production database.

Conclusion and next step

The final sample keeps authority in ordinary code: a trusted tenant constructs the tool, the model sees two bounded arguments, the command targets one reporting view with typed parameters, and three distinct runtime limits constrain different layers. The next step is not broader model-generated SQL. It is a disposable database integration review that verifies the principal, grants, tenant defense, query plan, and failure behavior under the intended deployment.

KEEP EXPLORING

Sources & further reading

  1. Configure parameters with Microsoft.Data.SqlClient
  2. Get started with Database Engine permissions
  3. SQL Server row-level security
  4. SqlCommand.CommandTimeout property
  5. SqlCommand.ExecuteReaderAsync method
  6. FunctionInvokingChatClient iteration limit
  7. Microsoft Agent Framework function tools
Take the code with you.Download the example project, setup guide, and tests.

Share this article

Bora Yılmaz
ABOUT THE AUTHOR

Bora Yılmaz

Backend developer with a Computer Engineering degree and a professional software development background dating to 2015. His work spans C#, .NET, APIs, enterprise integrations, and software architecture.

FIND YOUR NEXT SOLUTION

What are you building?

Explore practical tutorials, developer workflows, and architecture guides.

AI App Development ↗AI Developer Tools ↗AI Agents ↗
YOUR PRIVACY, YOUR CHOICE