Engineering Notes01 February 20267 min read

Least-Privilege MCP: Running Model Context Protocol Servers in Production

A practical guide to running Model Context Protocol servers in production with least-privilege database roles, tool allow-lists, and audit logging.

PanneerSystems Engineer
FIGURE 1.0
EDITORIAL REFERENCE
Rows of illuminated servers in a data center representing production infrastructure
CONTEXT:Rows of illuminated servers in a data center representing production infrastructure
REFERENCE:Taylor Vick
ON THIS PAGE (12 SECTIONS)
EXECUTIVE BRIEFING · KEY TAKEAWAYS
  • ✓Give an MCP server the narrowest possible database role — read-only, schema-scoped — instead of reusing your application's full-access credentials.
  • ✓Split read and write capabilities into separate MCP servers so a single misbehaving agent can't reach a write path it was never meant to use.
  • ✓Log every tool call your MCP server handles; the audit trail matters more once an autonomous agent, not a person, is the one calling the tools.

As more teams wire AI agents into internal systems through the Model Context Protocol, most of the available guidance covers what MCP is, not how to run it safely once the "client" calling your tools is autonomous rather than a person clicking a button. The answer is the same principle infrastructure teams have applied for decades to any new class of caller: least privilege, enforced at every layer — the database role backing the server, the tools you choose to register, and the process the server runs in.

MCP itself is simple: a server exposes a set of named tools and resources, a client — usually an AI agent — discovers them and calls them with structured arguments, and the server executes and returns a result. Nothing about that protocol forces you to be careful. The care has to come from how you build the server.

The Confused Deputy Problem

An MCP server usually runs under one identity, but it's being driven by an agent that has no identity of its own recognized by your database. If you point that server at your application's main database user, the agent — mediated only by which tools you happened to register — now has effectively the same reach as your application. Add prompt injection into the mix, where text returned from one tool call convinces the model to make a very different follow-up call than the operator intended, and you have a system where the boundary that matters isn't "what tools exist," it's "what the underlying credential is actually allowed to do." That underlying credential is your real security boundary. Tool descriptions are not.

Building a Read-Only MCP Server

Start at the database. Create a role that can only read, grant it explicitly, and don't reuse it for anything else. This means that even if every application-level check you write has a bug, the database itself refuses the write.

sql
1CREATE ROLE mcp_reader WITH LOGIN PASSWORD '...';
2REVOKE ALL ON SCHEMA public FROM mcp_reader;
3GRANT USAGE ON SCHEMA public TO mcp_reader;
4GRANT SELECT ON ALL TABLES IN SCHEMA public TO mcp_reader;
5ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO mcp_reader;

Then build the server around a small, explicit set of structured tools — not a single "run_sql" tool that accepts arbitrary queries. Structured tools are easier to reason about, easier to log meaningfully, and harder to misuse even if the model's arguments are unusual.

javascript
1import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3import { Pool } from 'pg';
4 
5const pool = new Pool({ user: 'mcp_reader', database: process.env.PGDATABASE });
6const server = new Server({ name: 'postgres-reader', version: '1.0.0' });
7 
8server.registerTool('list_tables', async () => {
9 const { rows } = await pool.query(
10 `SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'`
11 );
12 return rows;
13});
14 
15server.registerTool('describe_table', async ({ table }) => {
16 const { rows } = await pool.query(
17 `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1`,
18 [table]
19 );
20 return rows;
21});
22 
23// Deliberately no execute_query or write tool is registered here.
24server.connect(new StdioServerTransport());

Wiring the Server Into an Agent

The client-side configuration just needs to point at the process and its scoped credentials. Nothing in this file grants any capability beyond what the `mcp_reader` role already allows — that redundancy is the point.

json
1{
2 "mcpServers": {
3 "postgres-reader": {
4 "command": "node",
5 "args": ["./mcp-servers/postgres-reader.js"],
6 "env": {
7 "PGDATABASE": "app_production",
8 "PGUSER": "mcp_reader"
9 }
10 }
11 }
12}

Separating Read and Write Channels

If an agent genuinely needs to trigger a write — creating a ticket, for example — resist the temptation to grant it broad database access "just for this one case." Build a second, separate MCP server that exposes exactly one narrow, validated tool for that specific action, running under its own limited role. A single misbehaving or confused agent session should never be able to reach a write path it wasn't explicitly given for the task at hand.

The Mistake That Undoes All of This: Reusing the App's Main Credential

The single most common way an MCP setup ends up with no real boundary at all is pointing it at the same database user the main application uses, "temporarily," to get something working over a deadline. The tool allow-list still looks narrow from the agent's side, but the actual permission backing the connection is everything the application can do — which means a bug in the server code, a prompt injection that convinces the model to call a tool with unexpected arguments, or a future engineer adding one more tool without thinking hard about it can all reach far past what anyone intended. The fifteen minutes it takes to create a dedicated role up front is the difference between a scoped mistake and an unscoped one.

Secrets Belong in Environment Variables, Not Command Arguments

Passing a database password as a literal command-line argument means it shows up in `ps aux` output and in any process list a shared host exposes — visible to anything else running on that machine, not just the intended caller. Pass credentials through environment variables instead, as the config example above does, and rotate them on a schedule rather than treating them as a one-time setup step. An MCP server process is often long-running, which makes it exactly the kind of credential that's easy to forget about once it's wired up and working.

Timeouts and Response Size Limits

A read-only role stops an agent from writing, but it doesn't stop a tool call from requesting a query that scans a huge table or returns a result set large enough to blow past the model's context window and stall the entire agent session. Every tool should enforce its own statement timeout and cap the number of rows it will ever return, independent of what the caller asked for.

javascript
1server.registerTool('describe_table', async ({ table }) => {
2 const client = await pool.connect();
3 try {
4 await client.query('SET statement_timeout = 2000'); // 2s cap per call
5 const { rows } = await client.query(
6 `SELECT column_name, data_type FROM information_schema.columns WHERE table_name = $1 LIMIT 200`,
7 [table]
8 );
9 return rows;
10 } finally {
11 client.release();
12 }
13});

Testing an MCP Server Like Any Other API

A useful habit is to test the server's tools directly, outside of any agent, the same way you'd test a REST endpoint. Write integration tests that call `list_tables` and `describe_table` against a seeded test database and assert both the expected output and the expected failure: that calling anything resembling a write returns an error rather than silently succeeding. Tools like the standard MCP inspector are useful for manual exploration during development, but the permission boundary itself should be verified by a deterministic test, not by manually trying to break it once and moving on.

Auditing Every Tool Call

Once an agent, not a person, is the one calling your tools, the audit log becomes the thing you actually rely on when something looks wrong. Wrap every tool handler with logging that records the tool name, redacted arguments, duration, and outcome, and write it somewhere durable — treat it exactly like an API access log.

javascript
1function withAuditLog(toolName, handler) {
2 return async (args) => {
3 const startedAt = Date.now();
4 try {
5 const result = await handler(args);
6 auditLog.write({ tool: toolName, args: redactSecrets(args), durationMs: Date.now() - startedAt, status: 'ok' });
7 return result;
8 } catch (err) {
9 auditLog.write({ tool: toolName, args: redactSecrets(args), durationMs: Date.now() - startedAt, status: 'error', error: err.message });
10 throw err;
11 }
12 };
13}

Watch for Prompt Injection Through Tool Results

Data coming back from a tool — a customer note, a ticket description — can contain text that reads like an instruction to the model. Treat every tool result as untrusted content, not as a new set of directions, and never let a tool result alone trigger a follow-up call to a more sensitive tool without going through the same allow-list and logging path as any other request.

What This Looks Like at Vasiva

Internally, the MCP channels we've wired up for engineers assisting with product work — around systems like WorkHub and Dima — are read-only and schema-scoped by default. An agent can help an engineer understand a schema or trace a data issue; it doesn't get a path to write against production. That's the same rule we apply everywhere else: AI drafts and explores, engineers decide, and the database enforces the boundary either way.

If you're connecting agents to internal systems and want the access model reviewed before it ships, that's squarely AI Integration work for us — talk to us about your setup.

ACADEMIC & INDUSTRY CITATIONS

References & Prior Art

REF 01Open Protocol

Model Context Protocol (MCP) Open Specification v1.0

Anthropic Engineering • Anthropic PBC (2024)

Source
REF 02Security Standard

Guide to Attribute Based Access Control (ABAC) Definition and Considerations (SP 800-162)

Vincent Hu, David Ferraiolo, Rick Kuhn • NIST (2019)

Source
VERIFIED CONTRIBUTOR

Panneer

Systems Engineer

Panneer works on Vasiva's systems architecture — database roles, MCP server isolation, and least-privilege tool execution boundaries.

CURATED ARCHIVE

Continue Reading

Contextually relevant engineering logs and production architecture notes.

SYSTEM ADVISORY

Have an AI workflow or complex system worth exploring?

Work directly with the senior engineers who designed and shipped these systems. Zero vendor lock-in, deterministic architectures, and first-party audit integrity.

Primary Domain:ai integration
Evaluate the opportunity