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.
- 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 ROLEand threeGRANTlines - 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 - Put the reader's connection string in the environment variable
SCHEMA_MCP_CONNECTION, never in a file - Save mcp.json as
.mcp.jsonin the repository root and commit it - Start Claude Code and approve the
schemaserver when it asks; in a script, pass--mcp-config .mcp.jsonand allow the three tools by name - Ask which tables and columns store something. You should see
mcp__schema__list_tablesandmcp__schema__describe_tablecalls - 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.
| Server build | What refused | Message |
|---|---|---|
| As published | The server's own check | Refused by SchemaMcp: only a single SELECT statement is allowed. |
| Check switched off | The read-only transaction | 25006: cannot execute DELETE in a read-only transaction |
| Check and transaction switched off | The role's privileges | 42501: 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.