惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

U
Unit 42
S
Securelist
小众软件
小众软件
WordPress大学
WordPress大学
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
B
Blog
Cyber Security Advisories - MS-ISAC
Cyber Security Advisories - MS-ISAC
The GitHub Blog
The GitHub Blog
Apple Machine Learning Research
Apple Machine Learning Research
博客园 - 司徒正美
博客园 - Franky
Hugging Face - Blog
Hugging Face - Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
酷 壳 – CoolShell
酷 壳 – CoolShell
O
OpenAI News
Cloudbric
Cloudbric
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
TaoSecurity Blog
TaoSecurity Blog
MongoDB | Blog
MongoDB | Blog
K
KPMG report finds enterprise disconnect between AI and its ROI | CIO
V
V2EX
PCI Perspectives
PCI Perspectives
T
Troy Hunt's Blog
Schneier on Security
Schneier on Security
P
Palo Alto Networks Blog
M
MIT News - Artificial intelligence
V2EX - 技术
V2EX - 技术
阮一峰的网络日志
阮一峰的网络日志
Hacker News - Newest:
Hacker News - Newest: "LLM"
G
Google Developers Blog
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
The Last Watchdog
The Last Watchdog
The Register - Security
The Register - Security
腾讯CDC
N
News and Events Feed by Topic
C
Check Point Blog
爱范儿
爱范儿
T
Tailwind CSS Blog
Webroot Blog
Webroot Blog
P
Proofpoint News Feed
S
Schneier on Security
MyScale Blog
MyScale Blog
N
News | PayPal Newsroom
Recorded Future
Recorded Future
T
Tenable Blog
I
InfoQ
www.infosecurity-magazine.com
www.infosecurity-magazine.com
Microsoft Security Blog
Microsoft Security Blog
Simon Willison's Weblog
Simon Willison's Weblog
Engineering at Meta
Engineering at Meta

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant Common SOC 2 Failures (Real World) Stop Vibe-Checking Your AI App: A Practical Guide to Evals How to Use SonarQube and SonarScanner Locally to Level Up Your Code Quality Your Next To-Do App Is Dead — I Replaced Mine with an OpenClaw AI Sign a Nostr event in 60 lines of Python using coincurve — no nostr-sdk, no nbxplorer, no rust toolchain ITGC Audit Explained Like You’re in Big 4 Patch Tuesday abril 2026: Microsoft parcha 163 vulnerabilidades y un zero-day en SharePoint Stop scraping everything: a better way to track competitor price changes Listing on MCPize + the Official MCP Registry while routing payments OUTSIDE the marketplace — how I kept 100% of my x402 revenue Building an AI-Powered Risk Intelligence System Using Serverless Architecture Why We Ripped Function Overloading Out of Our AI Toolchain Testing AI-Generated Code: How to Actually Know If It Works SaaS Churn Is Killing Your Business. Here Is What to Do About It (Without a Support Team) The Speed of AI Is No Longer Linear - And Self-Improving Models Are Why How to Implement RBAC for MCP Tools: A Practical Guide for Engineering Teams From Standard Quote to Persuasive Proposal: AI Automation for Arborists I built a CLI that scaffolds complete multi-tenant SaaS apps Axios CVE-2025–62718: The Silent SSRF Bug That Could Be Hiding in Your Node.js App Right Now The dashboard that ended our friendship Data Pipelines Explained Simply (and How to Build Them with Python) The Hidden Cost of AI Systems Nobody Talks About. undefined vs undeclared, and how typeof behaves Switching from file-based jobs to NATS/Kafka in Rust without changing code io_uring Adventures: Rust Servers That Love Syscalls Why Agentic AI is Killing the Traditional Database The POUR principles of web accessibility for developers and designers Quantum Neural Network 3D — A Deep Dive into Interactive WebGL Visualization How To Install Caveman In Codex On macOS And Windows Automation Pipeline Reliability: Why Your Workflow Breaks When Nobody Is Watching I Built an 'Open World' AI Coding Agent — It Works From ANY Folder From Freelancing to Product: A Tech Service Company's SaaS Transformation China's AI Giants: Adding Tencent Hunyuan & ByteDance Doubao to AI University (74 Providers) On the Vibe Coders and Their Lies clerk: Auto-Summarize Your Claude Code Sessions AI Weekly — 2026/04/10–04/17 | The Model Lockdown Is Here, but the Toolchain Is the Real Battleground AI 週報 — 2026/04/10–2026/04/17 模型封鎖潮來了,但工具鏈才是真戰場 Maybe this is how Open-Source apps are born... 🚀 Fine-Tune LLMs with LoRA and QLoRA: 2026 Guide tRPC v11 + Next.js App Router: End-to-End Type Safety Without the Boilerplate ShadCN UI in 2026: Why I Stopped Installing Component Libraries and Started Owning My Components SaaS Billing in React Server Components: Stripe + Supabase Without a Single `useEffect` Join our DEV Weekend Challenge — $1,000 in Prizes Across TEN winners! Submissions Due April 20 at 6:59 AM UTC. Implementing FSRS Spaced Repetition in Flutter + Supabase — Adding Memory Science to an AI Learning App "I Texted My Localhost From the Train — Claude Code Fixed the Bug Before I Got Home" I Built a Sales Prep AI and It Went Deeper Than Expected Design to Code #2: One JSON, Eleven Outputs Solving the 100M-Row Problem: A Summary Table Pattern for High-Volume Push Notification Logs Flutter Web With Wasm: What Actually Changes For Developers I Built 50 Royalty-Free Soundtracks for My Side Project in a Weekend Using AI Music Generation The Vibe Coding Security Checklist: 7 Things to Check Before You Ship Stop Letting Googlebot Guess Fix Your React App's SEO Right Desconstruindo o Streaming do LinkedIn: Como Criar um Engine de Extração de Vídeo de Alta Performance com HLS e FFmpeg (EDA Part-1) EDA (Exploratory Data Analysis) Explained With Real Life — Why Looking at Your Data Is the Most Important Step in Machine Learning Brand Relationship Management at Scale: Our 4-Touch Outreach System for 200+ Brands Why String.fromEnvironment() Might Return an Empty String in Dart JGuardrails 1.0.0 — Hardening Java LLM Apps Against Jailbreaks, Toxicity, and Prompt Injection Plan and Schedule a Full Week of Threads Content From One Claude Conversation Coding Cat Oran Ep3, Five Tables Changed Everything Updated: BFF Pattern I'm done watching freelancers get buried by 200 proposals. So I'm building the alternative. This is my first post BFS Algorithm in Java Step by Step Tutorial with Examples Tracking LLM Pricing Monthly: An Open Dataset for 22 AI Models How We Measure Content ROI on a Comparison Site: Revenue Attribution Without Perfect Data Introducing Nova AI Ops: The AI-Native Operating System for SRE Teams I built a free desktop video downloader for Windows — Grabbit How Talkie OCR Helps Vision-Impaired & Dyslexic Users Read the World Around Them VRCFaceTracking安装和iPhone面捕配置教程,有bug Even CrowdStrike Can't See Your Agents The Automation Gold Rush: What n8n Workflows and Claude Are Opening Up for Developers Right Now
Building a Zero-Leak Postgres MCP Gateway in Go
Khalid Elokiely · 2026-06-27 · via DEV Community

The promise of agentic AI workflows introduces a critical architectural paradox: to make an LLM deeply useful, you must grant it structural awareness of your data layer. Traditional integration patterns force a losing trade-off. Either you hand an external orchestrator direct database access (risking catastrophic data egress), or you must serialize and persist your entire proprietary database schema onto third-party infrastructure. This exposure of internal domain definitions outside the secure perimeter represents a massive intellectual property leak, stalling production AI adoption in highly competitive or regulated sectors. For instance, a localized real estate consultancy managing proprietary compound metrics and high-value transactional ledgers cannot afford to expose its structural competitive edge to a shared cloud context just to run an analytical prompt.

To bridge this gap, backend teams must shift toward an architectural pattern where the data plane isolates schema definitions and executes only the commands explicitly defined by the MCP server, delivering pre-approved aggregations without ever leaking raw data layouts upstream. This article demonstrates how to build a zero-leak database proxy in Go using the Model Context Protocol (MCP) over a secure stdio transport layer. By decoupling the LLM from direct database access, you will implement a live gateway that executes two core tasks: Dynamic Schema Reflection to auto-generate tool manifests programmatically, and Analytical Egress Hardening to ensure the external AI agent never touches a raw database row.

The project follows a standard go folder layout - cmd/ for the entrypoint, pkg/db for the Postgres connection and logic. This isn’t a framework requirement, just a convention that keeps schema reflection, query execution and MCP transport cleanly separated. You can flatten this into a single file for prototyping.

The MCP zero-leak architecture

Three things make this gateway zero-leak;

  1. Schema Visibility: What it’s allowed to see from the data source (EXPOSED_TABLES)
  2. Aggregation: What it’s allowed to compute
  3. Tool Registration to the MCP server: How these become callable by the LLM.

The Schema Visibility

Schema visibility step utilizes Postgres’ information_schema.columns table to actually fetch column metadata from the database - instead of having to hardcode or dump it out of our database every time the LLM needs to know about what schema structure is available in our data layer.

In pkg/db/postgres.go we create an InspectExposedSchema function that returns a slice of type ColumnMetadata which can eventually be passed into the LLM context window.

package db

import (
    "database/sql"
)

// ColumnMetadata defines a single column in our postgres database
type ColumnMetadata struct {
    TableName  string
    ColumnName string
    DataType   string
}

// InspectExposedSchema reads structural layout data dynamically from the system catalog.
func InspectExposedSchema(db *sql.DB, exposedTables []string) ([]ColumnMetadata, error) {
    query := `
        SELECT table_name, column_name, data_type 
        FROM information_schema.columns 
        WHERE table_schema = 'public' 
        AND table_name = ANY($1)
        ORDER BY table_name, column_name;`

    rows, err := db.Query(query, exposedTables)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    var metadata []ColumnMetadata
    for rows.Next() {
        var col ColumnMetadata
        if err := rows.Scan(&col.TableName, &col.ColumnName, &col.DataType); err != nil {
            return nil, err
        }
        metadata = append(metadata, col)
    }

    if err := rows.Err(); err != nil {
        return nil, err
    }

    return metadata, nil
}

In the project we’ve setup a .env file with the following variable:

EXPOSED_TABLES=compounds,sales_ledger

This variable is read and passed into the InspectExposedSchema function to fetch only those tables that we’ve explicitly whitelisted for visibility.

It’s worth dwelling on why this is a deny-by-default allowlist rather than an exposed-by-default filter.

A more generic approach would remove the filter entirely, but in a regulated FinTech or real estate platform, that's not a hypothetical risk. Staging tables, audit logs, or a users table with national ID numbers would become visible to the orchestrator the moment they're created, with zero code change and zero review. The allowlist isn't extra friction, it's the only thing standing between "the LLM sees what we intended" and "the LLM sees whatever the last migration happened to leave lying around.”

Important note on the choice of environment variables in this article:

In this article we’re only highlighting a single filter level (table level). But a more production-ready design would include a deeper deny list on a more granular level for columns such as surrogate keys, create/delete/update timestamps or vector fields if you’re using PGVector.

Aggregation

Raw query access is the obvious approach — and the wrong one. Here's why the gateway pre-defines every computation the LLM is allowed to run. For this project, we are taking on one business case where the user of the LLM needs an aggregate of the total number of units sold (units_sold), total revenue made (revenue_egp) and total cancelled orders (cancelled_orders) for a specific region

In the schema provided in the repository, we have 2 entities compounds and sales_ledger . sales_ledger column compound_id is a foreign key that references compounds.id .

In many popular MCP implementations, the LLM would generally create the aggregation query and send it as plain-text for execution. This poses massive security risk - aside from DELETE or DROP statements which are naive assumptions given a read-only access. The real risk is an exhaustive SELECT query. There is no telling what the LLM might decide is the best path. For the majority of cases it might send the correct query for the business need directly.

-- Find aggregate of units sold, revenue, cancelled orders 
-- relative to a select region
SELECT compounds.region, 
        sum(units_sold) AS TOTAL_UNITS_SOLD,
                sum(revenue_egp) AS TOTAL_REVENUE, 
                sum(cancelled_orders) AS TOTAL_CANCELLED
FROM sales_ledger JOIN compounds ON sales_ledger.compound_id = compounds.id
WHERE compounds.region = ANY($1)
GROUP BY compounds.region

But if an attacker were to hijack a session or acquire access to the server running the LLM, there is no stopping them from instructing or injecting a prefix to the context window that instructs the LLM to pull raw data to the server and process it instead of aggregate it.

A more secure gateway only allows the LLM to know what it must know - without any possibility of further hijacking.

In pkg/db/queries.go we initialize a Queries struct and constructor for it which accepts a *sql.DB connection:

type Queries struct {
    db *sql.DB
}

func NewQueries(db *sql.DB) *Queries {
    return &Queries{
        db: db,
    }
}

Then we create the result struct for the first type of aggregation which consists of all the fields that represent a single record out from the above query.

type RegionalMetricsResult struct {
    Region          string  `json:"region"`
    UnitsSold       int     `json:"unitsSold"`
    TotalRevenue    float64 `json:"totalRevenue"`
    CancelledOrders int     `json:"cancelledOrders"`
}

Finally, we create FindRegionalMetrics method on Queries struct with a pointer receiver:

func (q *Queries) FindRegionalMetrics(ctx context.Context, regions []string) ([]RegionalMetricsResult, error) {
    query := `SELECT compounds.region, 
                         sum(units_sold) AS TOTAL_UNITS_SOLD,
                             sum(revenue_egp) AS TOTAL_REVENUE, 
                             sum(cancelled_orders) AS TOTAL_CANCELLED
                FROM sales_ledger JOIN compounds ON sales_ledger.compound_id = compounds.id
                WHERE compounds.region = ANY($1)
                GROUP BY compounds.region`

    rows, err := q.db.QueryContext(ctx, query, pq.Array(regions))

    if err != nil {
        return nil, err
    }

    defer rows.Close()

    var result []RegionalMetricsResult

    for rows.Next() {
        var col RegionalMetricsResult
        if err := rows.Scan(&col.Region, &col.UnitsSold, &col.TotalRevenue, &col.CancelledOrders); err != nil {
            return nil, err
        }

        result = append(result, col)
    }

    if err := rows.Err(); err != nil {
        return nil, err
    }

    return result, nil
}

pq.Array is required here because Go's database/sql doesn't natively serialize a string slice to Postgres's ANY($1) array syntax - the lib/pq driver wrapper handles that translation.

The FindRegionalMetrics and any similar method absolutely doesn’t have to know about who is calling it. It doesn’t care if the caller is an MCP server or a CRUD API server. It is pure business logic that constricts and abstracts flow from the underlying data store, essentially telling the LLM what it is allowed to do with the data.

This is also true in case your team decides to create a more complex and dynamic aggregate implementation - The end goal remains the same: You give the LLM a sparse set of information proxies that cannot be abused even if an attacker gains access.

Registering the functions as MCP Tools

Now comes the part where we register these tools as discoverable and usable utilities to the LLM.

For this project, we are using github.com/mark3labs/mcp-go to register MCP tools and run the MCP server.

First, we define a small helper that serializes any result type to indented JSON before returning it to the MCP transport layer. Using any as the input type means this same function works for every tool response — schema metadata, regional metrics, or any future query result.

func formatResult(v any) string {
    b, _ := json.MarshalIndent(v, "", "  ")
    return string(b)
}

In production, the marshal error should be handled explicitly. For this gateway, marshaling failures on known struct types are effectively impossible, but the pattern should be hardened before shipping.

The library makes it easy to add a descriptor for the tools using the mcp.NewToolmethod.

For the list_tables tool - initialize a tool name and the description:

    listTablesTool := mcp.NewTool("list_tables",
        mcp.WithDescription("Lists all available database schemas and field structures without exposing raw database records."),
    )

Then use the AddTool method to actually make the tool usable and utilize the InspectExposedSchema function we created above:

    s.AddTool(listTablesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        cols, err := db.InspectExposedSchema(database, exposedTables)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("Failed to map system constraints: %s", err.Error())), nil
        }

        return mcp.NewToolResultText(formatResult(cols)), nil
    })

The first line of the function has 2 important things to note:

     cols, err := db.InspectExposedSchema(database, exposedTables)

database is a variable holding the *sql.DB instance.

exposedTables is the largely configurable .env EXPOSED_TABLES variable we introduced earlier. This tells the InspectExposedSchema to only pull the information for the explicitly allowed tables.

Next, comes the aggregate method registration. First, initialize the Queries struct:

    queries := db.NewQueries(database)

The FindRegionalMetrics expects a slice of strings for its second argument regions []string . The get_metrics MCP tool can be configured in the mcp.NewTool method to annotate that this tool requires a string slice:

    metricsTool := mcp.NewTool("get_metrics",
        mcp.WithDescription("Retrieves metrics for specified geographical regions"),

        // Define your slice parameter here
        mcp.WithArray("region",
            mcp.Required(), // <-- This marks the parameter as required in the JSON Schema
            mcp.Description("A list of regions to filter metrics by (e.g. ['New Cairo', 'North Coast'])"),
        ),
    )

The mcp.WithArray tells the MCP server to expect a json array.

Next add the tool:

    s.AddTool(metricsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        regions, err := request.RequireStringSlice("region")
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }

        result, err := queries.FindRegionalMetrics(ctx, regions)
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }

        return mcp.NewToolResultText(formatResult(result)), nil
    })

The first line:

        regions, err := request.RequireStringSlice("region")

Is important because in the tool description, we only hinted at providing an Array . This method request.RequireStringSliceenforces a typed Array translating to a go StringSlice .

As covered in the previous section, pq.Array handles the Go-to-Postgres array serialization that database/sql doesn't provide natively.

The MCP server now exposes exactly two tools - no more, no less. The LLM can discover what exists and compute what's permitted. Everything else in the database remains invisible.

Wiring it all Together

Now to see the entire structure come to life, we wire together all that was built above into an entrypoint.

As mentioned previously we’re using github.com/mark3labs/mcp-go to spin up an MCP server instead of building one from scratch.

In this project the main.go is located in a standard path cmd/gateway/main.go . The full main.go looks like this:

package main

import (
    "context"
    "database/sql"
    "encoding/json"
    "fmt"
    "log"
    "mcp-postgres-gateway/pkg/db"
    "os"
    "strings"

    "github.com/joho/godotenv"
    _ "github.com/lib/pq" // CRITICAL: Must be explicitly imported here to register the driver
    "github.com/mark3labs/mcp-go/mcp"
    "github.com/mark3labs/mcp-go/server"
)

func formatResult(v any) string {
    b, _ := json.MarshalIndent(v, "", "  ")
    return string(b)
}

func main() {
    err := godotenv.Load(".env")
    // Initialize Postgres Connection
    connStr := os.Getenv("DATABASE_URL")

    exposedTables := strings.Split(os.Getenv("EXPOSED_TABLES"), ",")
    if len(exposedTables) == 0 {
        log.Fatal("EXPOSED_TABLES environment variable is not set")
    }

    if connStr == "" {
        log.Fatal("DATABASE_URL environment variable is not set")
    }

    database, err := sql.Open("postgres", connStr)
    if err != nil {
        log.Fatalf("Database initialization failure: %v", err)
    }
    defer database.Close()

    // Establish the MCP Core Server Block
    s := server.NewMCPServer("domainai-gateway", "1.0.0")

    // 1. Tool 1 Implementation: Expose Schema Table Information
    listTablesTool := mcp.NewTool("list_tables",
        mcp.WithDescription("Lists all available database schemas and field structures without exposing raw database records."),
    )

    s.AddTool(listTablesTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        cols, err := db.InspectExposedSchema(database, exposedTables)
        if err != nil {
            return mcp.NewToolResultError(fmt.Sprintf("Failed to map system constraints: %s", err.Error())), nil
        }

        return mcp.NewToolResultText(formatResult(cols)), nil
    })

    // Data tools
    queries := db.NewQueries(database)

    metricsTool := mcp.NewTool("get_metrics",
        mcp.WithDescription("Retrieves metrics for specified geographical regions"),

        // Define your slice parameter here
        mcp.WithArray("region",
            mcp.Required(), // <-- This marks the parameter as required in the JSON Schema
            mcp.Description("A list of regions to filter metrics by (e.g. ['US', 'EU'])"),
        ),
    )

    s.AddTool(metricsTool, func(ctx context.Context, request mcp.CallToolRequest) (*mcp.CallToolResult, error) {
        regions, err := request.RequireStringSlice("region")
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }

        result, err := queries.FindRegionalMetrics(ctx, regions)
        if err != nil {
            return mcp.NewToolResultError(err.Error()), nil
        }

        return mcp.NewToolResultText(formatResult(result)), nil
    })

    // Start the Server to communicate natively over standard IO channels
    log.Println("MCP Gateway initialized. Establishing communication channel over Stdio...")
    if err := server.ServeStdio(s); err != nil {
        fmt.Fprintf(os.Stderr, "Server crash anomaly: %v\n", err)
        os.Exit(1)
    }
}

In this implementation, .env loading failures are intentionally non-fatal. The application falls back to system environment variables, which is the correct behavior in containerized deployments where .env files aren't present.

Notice how we must import github.com/lib/pq using alias _ for side effects. Once registered database/sql knows exactly how to handle the postgres protocol behind the scenes when you initialize a connection.

Also notice this block of code:

    connStr := os.Getenv("DATABASE_URL")

    exposedTables := strings.Split(os.Getenv("EXPOSED_TABLES"), ",")
    if len(exposedTables) == 0 {
        log.Fatal("EXPOSED_TABLES environment variable is not set")
    }

    if connStr == "" {
        log.Fatal("DATABASE_URL environment variable is not set")
    }

The application deliberately terminates the program if DATABASE_URL is not found in environment. But more notably this pattern is also enforced early in the program when no EXPOSED_TABLES are set. This can be helpful to save network resources and give an early failure signal if your MCP server communicates with the database service across another network or if the database service is a microservice in your ecosystem.

To test the MCP service, you can spin up a quick, on demand MCP inspector UI in your browser by running this npx command:

npx -y @modelcontextprotocol/inspector go run cmd/gateway/main.go

This should open up an MCP inspector tab in your browser.

Test the tools

Running list_tables tool should yield an output similar to this:

[
  {
    "TableName": "compounds",
    "ColumnName": "developer",
    "DataType": "character varying"
  },
  {
    "TableName": "compounds",
    "ColumnName": "id",
    "DataType": "integer"
  },
  {
    "TableName": "compounds",
    "ColumnName": "launch_year",
    "DataType": "integer"
  },
  {
    "TableName": "compounds",
    "ColumnName": "name",
    "DataType": "character varying"
  },
  {
    "TableName": "compounds",
    "ColumnName": "region",
    "DataType": "character varying"
  },
  {
    "TableName": "compounds",
    "ColumnName": "total_units",
    "DataType": "integer"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "cancelled_orders",
    "DataType": "integer"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "compound_id",
    "DataType": "integer"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "id",
    "DataType": "integer"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "quarter",
    "DataType": "character varying"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "revenue_egp",
    "DataType": "numeric"
  },
  {
    "TableName": "sales_ledger",
    "ColumnName": "units_sold",
    "DataType": "integer"
  }
]

Running the get_metrics tool with the input of ["New Cairo", "North Coast"] Should yield the below aggregated metrics for each region.

[
  {
    "region": "New Cairo",
    "unitsSold": 165,
    "totalRevenue": 1245000000,
    "cancelledOrders": 3
  },
  {
    "region": "North Coast",
    "unitsSold": 12,
    "totalRevenue": 180000000,
    "cancelledOrders": 4
  }
]

The LLM received aggregated metrics - totals, not rows. It knows New Cairo sold 165 units. It has no path to the individual transaction records that produced that number. That's the boundary the gateway enforces.

The takeaway

The Go ecosystem is underrepresented in MCP tooling — most implementations lean on Python or TypeScript. But the real gap isn't language choice. It's architectural discipline.

An MCP gateway that lets the LLM construct its own queries is only as secure as the LLM's judgment - and judgment is exactly what attackers exploit. The pattern in this article inverts that assumption: the gateway defines what's computable, the LLM executes within those boundaries, and raw data never crosses the perimeter.

This isn't a limitation of the architecture. It's the feature.

The full implementation is available at khalidelokiely/mcp-postgres-gateway. Clone it, point it at your own Postgres instance, and extend queries.go with the aggregations your business logic actually needs. The schema reflection and transport layer stay unchanged — only the computations you choose to expose are yours to define.