Before you let AI near your production data, you need to know which three methods it should never get. In this article we take a normal ASP.NET Core Web API (.NET 10, EF Core and PostgreSQL) and turn it into an MCP server that exposes only the read methods we choose. Then we connect Claude Code to it, show the same setup for GitHub Copilot, and look at the three methods AI should never get: delete, update and run any SQL.

Video: YOUTUBE LINK
Source code: GITHUB LINK
What we will build
An AI assistant that can answer questions such as "which products are running low on stock?" straight from a PostgreSQL database. It can search and read. It cannot update, delete or run its own SQL, because those methods do not exist on the server it talks to.
Step | Component | Role |
|---|
1 | AI client (Claude Code or GitHub Copilot) | Reads the tool descriptions and decides which tool to call |
2 | /mcp endpoint (MapMcp())
| The one new door into the API |
3 | ProductTools
| Three read-only methods, nothing else |
4 | EF Core + PostgreSQL | The same database the API already uses |
The existing ProductsController stays exactly as it is. The AI never calls it.
Why this matters
The quick way to connect AI to a database is one tool that runs any SQL. The AI writes the query, the app runs it, and in a demo it works. But the AI also reads text you do not control: a product review, an email, a web page. If that text says "delete all products", an AI with a run-any-SQL tool can do exactly that. One sentence, and the rows are gone.
The fix is not a better prompt. The fix is to never give the AI a method that can do damage.
MCP in one paragraph
Model Context Protocol (MCP) is a standard way for an AI assistant to call your code. You publish a small set of described methods, called tools. The assistant reads each description and decides when to call one. Clients such as VS Code and Claude speak MCP, so you write the server once. .NET has an official C# SDK for it: ModelContextProtocol.AspNetCore.
Prerequisites
.NET 10 SDK
PostgreSQL
An ASP.NET Core Web API using EF Core with the Npgsql provider
Claude Code, or GitHub Copilot in VS Code, to try it
Versions used in this article: .NET 10, ModelContextProtocol.AspNetCore 2.2.0, Npgsql.EntityFrameworkCore.PostgreSQL 10.0.0.
Step 1. The starting API
We start from a Web API with a Product entity. Notice the last two fields: they are internal and the AI must never see them.
namespace ProductsApi.Models;
public class Product
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Price { get; set; }
public int Stock { get; set; }
// Internal fields: these never reach the AI
public decimal CostPrice { get; set; }
public string Supplier { get; set; } = "";
}
The controller has the usual get, get by id, create, update and delete actions. We will not touch it.
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using ProductsApi.Data;
using ProductsApi.Models;
namespace ProductsApi.Controllers;
[ApiController]
[Route("api/[controller]")]
public class ProductsController(AppDbContext db) : ControllerBase
{
[HttpGet]
public async Task<List<Product>> GetAll() => await db.Products.ToListAsync();
[HttpGet("{id}")]
public async Task<ActionResult<Product>> Get(int id)
=> await db.Products.FindAsync(id) is { } p ? p : NotFound();
[HttpPost]
public async Task<ActionResult<Product>> Create(Product product)
{
db.Products.Add(product);
await db.SaveChangesAsync();
return CreatedAtAction(nameof(Get), new { id = product.Id }, product);
}
[HttpPut("{id}")]
public async Task<IActionResult> Update(int id, Product product)
{
if (id != product.Id) return BadRequest();
db.Entry(product).State = EntityState.Modified;
await db.SaveChangesAsync();
return NoContent();
}
[HttpDelete("{id}")]
public async Task<IActionResult> Delete(int id)
{
var product = await db.Products.FindAsync(id);
if (product is null) return NotFound();
db.Products.Remove(product);
await db.SaveChangesAsync();
return NoContent();
}
}
Step 2. Add the MCP SDK
dotnet add package ModelContextProtocol.AspNetCore
Step 3. Write a separate tools class
Do not hand the AI your controller. Create a new folder called Tools and a class called ProductTools, and put in it only what the AI is allowed to do. The class receives the DbContext through its constructor like any other service.
using System.ComponentModel;
using Microsoft.EntityFrameworkCore;
using ModelContextProtocol.Server;
using ProductsApi.Data;
namespace ProductsApi.Tools;
public record ProductDto(int Id, string Name, decimal Price, int Stock);
[McpServerToolType]
public class ProductTools(AppDbContext db)
{
[McpServerTool, Description("Search products by name. Returns at most 20 results.")]
public async Task<List<ProductDto>> SearchProducts(
[Description("Part of the product name, e.g. 'mouse'")] string term)
=> await db.Products.AsNoTracking()
.Where(p => EF.Functions.ILike(p.Name, $"%{term}%"))
.Take(20)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock))
.ToListAsync();
[McpServerTool, Description("Get one product by its id.")]
public async Task<ProductDto?> GetProduct(int id)
=> await db.Products.AsNoTracking()
.Where(p => p.Id == id)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock))
.FirstOrDefaultAsync();
[McpServerTool, Description("List products whose stock is below the given number.")]
public async Task<List<ProductDto>> LowStock(
[Description("Stock threshold, e.g. 10")] int below)
=> await db.Products.AsNoTracking()
.Where(p => p.Stock < below)
.OrderBy(p => p.Stock)
.Take(20)
.Select(p => new ProductDto(p.Id, p.Name, p.Price, p.Stock))
.ToListAsync();
}
Four details keep it safe:
AsNoTracking() marks every query as read-only.
Take(20) stops the AI from pulling an entire table into one answer.
ProductDto is the only shape the AI sees. Cost price and supplier are not in it, so they can never leak.
The Description text is what the AI reads to decide when to call a tool. Write it the way you would explain the method to a new developer on your team.
EF.Functions.ILike is specific to PostgreSQL. On SQL Server, use EF.Functions.Like or Contains.
Step 4. Register the server in Program.cs
Three additions: the using for the tools namespace, the AddMcpServer chain, and MapMcp("/mcp") after MapControllers().
using Microsoft.EntityFrameworkCore;
using ProductsApi.Data;
using ProductsApi.Tools;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(builder.Configuration.GetConnectionString("Default")));
builder.Services.AddControllers();
builder.Services
.AddMcpServer()
.WithHttpTransport()
.WithTools<ProductTools>();
var app = builder.Build();
// Demo only: create the database and seed data on startup
using (var scope = app.Services.CreateScope())
{
scope.ServiceProvider.GetRequiredService<AppDbContext>().Database.EnsureCreated();
}
app.MapControllers();
app.MapMcp("/mcp");
app.Run();
Your controllers keep working. The MCP endpoint sits beside them at /mcp. Note that the C# SDK publishes tool names in snake case, so LowStock becomes low_stock.
Step 5. Run it
The connection string in appsettings.json uses a placeholder password. Pass the real one through an environment variable instead of committing it:
$env:ConnectionStrings__Default = 'Host=localhost;Port=5432;Database=productsdb;Username=postgres;Password=<your password>'
dotnet run
The API listens on http://localhost:5080 (check the port in Properties/launchSettings.json), and the MCP endpoint is http://localhost:5080/mcp.
Step 6. Connect Claude Code
Create a file called .mcp.json in the project root:
{
"mcpServers": {
"products-api": {
"type": "http",
"url": "http://localhost:5080/mcp"
}
}
}
In a second terminal, start Claude Code without its built-in tools:
claude --tools ""
--tools "" switches off Claude Code's own shell and file tools, so the only tools it has are the three from our API. The first time, Claude asks whether to use the MCP server from .mcp.json; choose to use it.
Type /mcp. You should see one server, products-api, connected, with exactly three tools: search_products, low_stock and get_product.
Now ask: "which products are running low on stock?" Claude calls low_stock with below: 10. This is what the tool returned against the seed data:
[
{ "id": 5, "name": "Laptop Stand", "price": 1299.0, "stock": 2 },
{ "id": 3, "name": "USB-C Hub", "price": 1899.0, "stock": 3 },
{ "id": 2, "name": "Mechanical Keyboard", "price": 3499.0, "stock": 6 },
{ "id": 7, "name": "Webcam 1080p", "price": 2499.0, "stock": 9 }
]
No cost price, no supplier: only the DTO fields. Then ask it to "delete product 5". Claude replies that it can't, because the products API only lets it read data. Product 5 is still in the database.
Windows tip: --tools "" needs a shell that passes an empty argument, such as Git Bash, cmd or PowerShell 7. Windows PowerShell 5.1 drops empty arguments, so the command fails there.
Using GitHub Copilot instead
The API does not change, only the config file. Copilot in VS Code reads .vscode/mcp.json, and the top-level key is servers instead of mcpServers:
{
"servers": {
"products-api": {
"type": "http",
"url": "http://localhost:5080/mcp"
}
}
}
Click Start above the server name in mcp.json so VS Code connects to it.
Open Copilot Chat and switch to Agent mode.
Open the tools list and keep only the three products-api tools ticked. Untick the built-in terminal and file-editing tools, otherwise agent mode can work around your API.
Ask "which products are running low on stock?" and Copilot calls low_stock.
The 3 methods AI should never get
Exposed (read only) | Never exposed |
|---|
SearchProducts: max 20 rows
| DeleteProduct: one misread sentence can call it
|
GetProduct: one row
| UpdateProduct: let the AI suggest, a human applies
|
LowStock: max 20 rows
| RunQuery(string sql): the whole database, handed over
|
Delete. Even if you only want it for cleanup, an AI that misreads one sentence can call it. If the method is not in ProductTools, it cannot happen.
Update. If the AI needs to change data, have it suggest the change and let a person confirm it in your own app.
Run any SQL. This is the tool many demos include. It gives a model the whole database, and models can be steered by text they read. Never ship it.
One more layer: a read-only database user
Give the connection used by the MCP tools a PostgreSQL role that can only read. Then even a bug in your own tool code cannot write.
CREATE ROLE ai_reader LOGIN PASSWORD 'change-me';
GRANT SELECT ON ALL TABLES IN SCHEMA public TO ai_reader;
Connected as ai_reader, a SELECT works and a DELETE fails with ERROR: permission denied for table Products. Wiring this user into the app needs a second connection string and DbContext registration for the tools; your controllers keep their normal read-write user.
Summary
The AI can do exactly three things: search, get one, and list low stock. Each returns a DTO with a row limit. It has no delete, no update and no raw SQL, because those methods were never written into the class it can see. Adding MCP to an existing Web API took one package, one class and two lines in Program.cs, and the existing controller did not change at all.
Next step, if you want it: the AI suggests an update, and a human approves it. Let me know in the comments.
Created by Ankush Agnihotri ([email protected]), Core Knowledge Sharing.
Join the conversation! Your thoughts help the community grow.