Claude Code can read your entity classes, but the classes are not the database. In the sample solution the migration says a column is TEXT and the real PostgreSQL table says timestamp with time zone. MCP is how Claude Code asks the database itself. This part does it without installing anyone else's server: about two hundred lines of C# on the official SDK, three tools, a role that can only read, and then three attempts to delete a row through it.

Steps in this part
  1. Create a login that can only read: run schema.sql.txt with psql, or take only its last section for your own tables. You should see CREATE ROLE and three GRANT lines
  2. Create the project: dotnet new console -n SchemaMcp -f net10.0 -o tools/SchemaMcp, add the packages from SchemaMcp.csproj.txt, and copy in Program.cs.txt and SchemaTools.cs.txt
  3. Put the reader's connection string in the environment variable SCHEMA_MCP_CONNECTION, never in a file
  4. Save mcp.json as .mcp.json in the repository root and commit it
  5. Start Claude Code and approve the schema server when it asks; in a script, pass --mcp-config .mcp.json and allow the three tools by name
  6. Ask which tables and columns store something. You should see mcp__schema__list_tables and mcp__schema__describe_table calls
  7. Ask it to delete a row, and read what refuses

The server

Three packages: ModelContextProtocol 2.2.0, the C# SDK for MCP and a stable release, Npgsql 10.0.3 and Microsoft.Extensions.Hosting. The entry point is short enough to show whole, minus the usings:

var connectionString = Environment.GetEnvironmentVariable("SCHEMA_MCP_CONNECTION");
if (string.IsNullOrWhiteSpace(connectionString))
{
    Console.Error.WriteLine("SchemaMcp: set SCHEMA_MCP_CONNECTION to a connection string for a read-only role.");
    return 1;
}

var builder = Host.CreateApplicationBuilder(args);
builder.Logging.AddConsole(options => options.LogToStandardErrorThreshold = LogLevel.Trace);

builder.Services.AddSingleton(NpgsqlDataSource.Create(connectionString));
builder.Services
    .AddMcpServer(options => options.ServerInstructions =
        "Read-only access to the application's PostgreSQL database: list the tables, describe one " +
        "table (columns, keys, indexes), or run a single SELECT with a row cap.")
    .WithStdioServerTransport()
    .WithTools<SchemaTools>();

await builder.Build().RunAsync();
return 0;

The logging line matters. Over stdio, standard output is the protocol, so every log line has to go to standard error or it corrupts the conversation. A tool is a method with two attributes; its description is what the model reads when deciding whether to call it:

[McpServerTool(Name = "run_select", ReadOnly = true)]
[Description("Runs ONE SELECT statement in a read-only transaction and returns at most 100 rows. " +
             "Anything else (INSERT, UPDATE, DELETE, DDL, several statements) is refused. " +
             "Quote mixed-case names: SELECT \"FullName\" FROM \"Doctors\".")]
public async Task<string> RunSelect(
    [Description("A single SELECT statement")] string sql,
    CancellationToken cancellationToken)

Inside, it checks that the text is one statement starting with SELECT, opens a transaction, runs SET TRANSACTION READ ONLY; SET LOCAL statement_timeout = '5s', reads at most 100 rows and never commits. The other two tools, list_tables and describe_table, read PostgreSQL's catalogs.

Connecting it

{
  "mcpServers": {
    "schema": {
      "type": "stdio",
      "command": "dotnet",
      "args": ["run", "--project", "tools/SchemaMcp"],
      "env": {
        "SCHEMA_MCP_CONNECTION": "${SCHEMA_MCP_CONNECTION}"
      }
    }
  }
}

The file is safe to commit because it holds a reference, not a value: Claude Code expands ${SCHEMA_MCP_CONNECTION} from each developer's environment when it starts the server. The command that writes the same file is claude mcp add --env 'SCHEMA_MCP_CONNECTION=${SCHEMA_MCP_CONNECTION}' --transport stdio --scope project schema -- dotnet run --project tools/SchemaMcp. Keep the single quotes. With double quotes a shell such as bash expands the variable first and writes your real connection string, password included, into the file you are about to commit.

The MCP documentation says an interactive session asks for approval before it uses a server from a project's .mcp.json. These runs were headless, where there is no prompt: the server connects without asking, and its tools appear as mcp__schema__list_tables, mcp__schema__describe_table and mcp__schema__run_select. They still need permission. Marking a tool read-only in the server grants nothing; without an allow rule the first call was denied:

"message": "Claude requested permissions to use mcp__schema__list_tables, but you haven't granted it yet."

What it answered

The question was Which tables and columns store appointment times, and how are doctors linked to appointments? The session first loaded the tools, which Claude Code keeps out of the context until they are needed, then listed the tables and described two of them. The answer quoted the live schema:

  StartsAt timestamp with time zone NOT NULL
  FK_Appointments_Doctors_DoctorId: FOREIGN KEY ("DoctorId") REFERENCES "Doctors"("Id") ON DELETE CASCADE

It read no C# at all, and it pointed out something the entity classes do not say out loud: "That foreign key is ON DELETE CASCADE: deleting a doctor also deletes all of their appointments." The same question with the MCP tools not allowed was answered from the migration files, and described the column as "stored as TEXT in SQLite". That is the difference the server makes: the database as it is, not as one provider's migration describes it.

What it did not change

The second question was Write the EF Core LINQ query for doctors who have no booked appointment tomorrow. With the server available, the model never called it. It read the entity classes and wrote a correct query; without the server it wrote the same query, character for character apart from line breaks. Run afterwards against PostgreSQL, it returned the right doctor, the one whose only appointment that day was cancelled. For writing code against a model that is already in the repository, the schema server added nothing.

Trying to delete a row

The plain request, Delete the test patient David Chen from the database., produced no write attempt. The model looked the patient up, read the tool's description and declined: "The only database access I have is the SchemaMcp server, which is read-only: it runs SELECT statements and refuses DELETE." That is good behaviour and it is not a control, so the refusal was tested on purpose, with a prompt that asked for the attempt and two test builds of the server with layers switched off.

The same DELETE sent through run_select, with layers removed one at a time
Server buildWhat refusedMessage
As publishedThe server's own checkRefused by SchemaMcp: only a single SELECT statement is allowed.
Check switched offThe read-only transaction25006: cannot execute DELETE in a read-only transaction
Check and transaction switched offThe role's privileges42501: permission denied for table Patients

After all three, the table still had its four patients. The order of importance is the reverse of the order in which they fire. The role is the safety net: it holds when everything else is gone. The read-only transaction is second. The check in the server is a convenience that gives a clear message without a trip to the database; it is a prefix test, not a SQL parser.

What read-only does not cover

If you point the connection string at the application's own login, or at an owner, the first layer is gone, and the server cannot tell. The role in schema.sql has CONNECT, USAGE and SELECT on three tables and nothing else; use one like it.

Read-only is also not private. Whatever a query returns goes into the model's context: in the delete run it fetched the patient's email address while looking him up. With real personal data, grant the role only the columns or views you are willing to share. And the server limits only itself. A session that is allowed to run shell commands could reach the database another way, which is a question for the permission rules of Part 2, not for this server. No session here was allowed a shell.

Test it by hand first

Before any Claude session, the server was driven with raw JSON-RPC over standard input: initialize, list the tools, call each one. That found two bugs no session would have explained. The first call to describe_table failed with only An error occurred invoking 'describe_table'., because the SDK hides the text of any exception that is not an McpException. The real error, an Npgsql type problem, was on standard error. And PostgreSQL 18 lists every NOT NULL as a constraint, which filled the output with noise until it was filtered. When a tool fails with a generic message, read the server's standard error.

One cost to know: dotnet run as the server command checks the build on every start. By hand the server answered its first request in two to three seconds. A slow restore on a cold machine could take longer than the 30 seconds Claude Code waits by default; that was not tested.

Frequently asked

How do I write an MCP server in C#?
Create a console project, add the ModelContextProtocol and Microsoft.Extensions.Hosting packages, call AddMcpServer().WithStdioServerTransport().WithTools<YourTools>() on the host builder, and mark each tool method with McpServerTool and a Description. Send all logging to standard error, because standard output carries the protocol.
How do I connect Claude Code to a PostgreSQL database safely?
Through an MCP server that logs in as a role with SELECT only, and runs each query in a read-only transaction. In this test a DELETE was refused three ways: by the server's own check, by the read-only transaction (25006), and by the role's privileges (42501). The role is the layer that matters.
Is it safe to commit .mcp.json?
Yes, if it holds no secrets. Reference the connection string as ${SCHEMA_MCP_CONNECTION} in the env block and set the variable on each machine; Claude Code expands it when it starts the server. With claude mcp add, put the --env value in single quotes so the shell does not write the real value into the file.

That is the series: instructions in Part 1, permissions, hooks, a skill, a reviewer and a database view. If you have not installed Claude Code yet, start with Set Up Claude Code for .NET Like You Mean It.