Building an MCP Server with TypeScript: Connecting PostgreSQL and Grafana to Hermes AI Agent
It was the middle of the night when my on-call shift hit. A Slack alert came in: "Payment service 5xx spike — cause unknown." Open the dashboard, connect to the DB, write the query, dig through Grafana panels... it took a full 30 minutes just to identify the cause. After we introduced MCP, that same scenario dropped to 15 minutes. Ask Hermes Agent, "Which services had API response times over 500ms in the last hour?" and it fires off PromQL to Prometheus, queries the DB, and delivers the analysis on its own.
I'll admit I initially thought, "Do I have to wire AI to internal systems using something complex like LangChain?" But with a single MCP (Model Context Protocol) server, you can make Hermes Agent directly operate PostgreSQL and Grafana. This architecture reduces what used to be M×N integration code down to M+N — and it's much faster to get started than you'd expect.
This article covers what MCP is, how to build a custom PostgreSQL MCP server in TypeScript, and the configuration needed to connect Prometheus and Grafana to Hermes Agent. Prerequisites: Node.js 18+, pnpm, an accessible PostgreSQL development DB, and Hermes Agent v0.2.0+ with MCP client mode support — see the official docs for installation. With this setup in place, you can have something running locally today.
Core Concepts
Why MCP Is Called "the USB-C for AI"
MCP is an open standard that Anthropic released in November 2024. Think of it as a protocol that standardizes how AI models communicate with external systems.
Previously, connecting AI to an internal system required building a custom interface each time. One for the DB, one for Slack, one for Jira... the integration code grew M×N as systems multiplied. MCP turns that into M+N. Build an MCP server once and it can be reused not just with Hermes Agent, but with Claude Code, Cursor, and any future MCP-compatible client.
The Three MCP Primitives
- Tools: Functions the agent can invoke (execute SQL, call APIs, etc.)
- Resources: Data the agent can read (files, DB records, etc.)
- Prompts: Reusable prompt templates
SDK downloads were 2 million per month at launch; by March 2026 they had surpassed 97 million, with OpenAI, Microsoft, and AWS all officially adopting it. Gartner predicts that 75% of API gateway vendors will support MCP by the end of 2026.
How Hermes Agent Connects to MCP
Hermes Agent is an open-source autonomous AI agent built by Nous Research. Starting from v0.2.0, it supports MCP client mode — simply register an MCP server in config.yaml and the agent automatically performs a handshake at startup, making that server's tools available.
# hermes config.yaml — the most basic structure
mcp_servers:
server-name:
command: "command to run"
args: ["arguments"]
allowed_tools:
- "tool_name_to_allow"allowed_tools matters. It lets you explicitly restrict which tools the agent can access, making it ideal for applying the principle of least privilege.
Transport Methods: stdio vs Streamable HTTP
There are two main ways to run an MCP server.
| Method | Characteristics | Best For |
|---|---|---|
| stdio | Communicates via standard I/O, simple setup | Local development, same machine as client |
| Streamable HTTP | HTTP-based remote communication, standardized March 2025 | Shared team servers, production deployments |
Honestly, the majority of official MCP servers currently still use stdio. For production use across a whole team, you'll need to go with Streamable HTTP — but that setup is more involved. For development and validation, starting with stdio is the right call.
Practical Implementation
Build It Yourself: Setting Up an Internal PostgreSQL MCP Server
Off-the-shelf options like postgres-mcp exist, but internal DBs often have their own security policies and query restrictions, making it better to build your own. Using the TypeScript SDK gives you type safety and lets you control the security layer directly in code.
pnpm init
pnpm add @modelcontextprotocol/sdk pg zod
pnpm add -D typescript @types/pg tsx// src/server.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { Pool } from "pg";
import { z } from "zod";
// Validate env var at startup — exit immediately before Pool creation if undefined
const DB_URL = process.env.DB_URL;
if (!DB_URL) {
console.error("오류: DB_URL 환경 변수가 설정되지 않았습니다.");
process.exit(1);
}
const server = new McpServer({ name: "internal-db-mcp", version: "1.0.0" });
const pool = new Pool({ connectionString: DB_URL, max: 5 });
// Whitelist for code-level reference
// Note: This list alone does not block DB-level access.
// Real access control must be handled via a read-only account + view permissions.
const ALLOWED_TABLES = ["orders", "services", "error_logs", "metrics"];
function validateSql(sql: string): string | null {
if (!/^\s*SELECT\b/i.test(sql)) return "SELECT 쿼리만 허용됩니다.";
// Block semicolons: defends against multi-statement injection like "SELECT 1; DROP TABLE orders"
if (/;/.test(sql)) return "세미콜론은 허용되지 않습니다.";
// Block comments: defends against filter bypass attempts using "--" or "/*" comments
if (/--|\/\*/.test(sql)) return "SQL 주석은 허용되지 않습니다.";
return null;
}
server.tool(
"query_metrics",
"지표 데이터 조회 (읽기 전용)",
{
sql: z.string().describe("실행할 SELECT 쿼리"),
limit: z.number().min(1).max(500).default(100),
},
async ({ sql, limit }) => {
const validationError = validateSql(sql);
if (validationError) {
return {
content: [{ type: "text", text: `오류: ${validationError}` }],
isError: true,
};
}
// If the original query already has LIMIT, adding another causes a syntax error — handle both cases
const hasLimit = /\bLIMIT\b/i.test(sql);
const finalSql = hasLimit ? sql : `${sql} LIMIT $1`;
const params = hasLimit ? [] : [limit];
try {
const result = await pool.query(finalSql, params);
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }],
};
} catch (err) {
return {
content: [
{
type: "text",
text: `쿼리 실행 오류: ${err instanceof Error ? err.message : "알 수 없는 오류"}`,
},
],
isError: true,
};
}
}
);
server.tool(
"list_tables",
"조회 가능한 테이블 목록 반환",
{},
async () => ({
content: [{ type: "text", text: ALLOWED_TABLES.join("\n") }],
})
);
server.tool(
"describe_table",
"테이블 스키마 조회",
{
// z.enum() requires a tuple type [string, ...string[]] with at least one element.
// This cast is needed because TypeScript infers ALLOWED_TABLES as a plain string[].
table_name: z.enum(ALLOWED_TABLES as [string, ...string[]]),
},
async ({ table_name }) => {
try {
const result = await pool.query(
`SELECT column_name, data_type, is_nullable
FROM information_schema.columns
WHERE table_name = $1
ORDER BY ordinal_position`,
[table_name]
);
return {
content: [{ type: "text", text: JSON.stringify(result.rows, null, 2) }],
};
} catch (err) {
return {
content: [
{
type: "text",
text: `스키마 조회 오류: ${err instanceof Error ? err.message : "알 수 없는 오류"}`,
},
],
isError: true,
};
}
}
);
const transport = new StdioServerTransport();
await server.connect(transport);Key points to note in the code:
| Point | Description |
|---|---|
DB_URL validation at startup |
Exit immediately before Pool creation if undefined — prevents silent failure |
validateSql() function |
SELECT-only + semicolon + comment blocking defends against multi-statement injection |
| LIMIT duplication check | Avoids syntax errors by not appending LIMIT if the original query already has one |
| try/catch handling | Returns error response on DB connection failure, timeout, or syntax error |
max: 5 connection pool |
Limits DB load when the agent fires off a burst of queries |
z.enum(ALLOWED_TABLES) |
Validates table names at the type level via Zod schema |
The most important part of the security design: The regex in
validateSql()is a secondary line of defense. The primary defense is creating a dedicated read-only role (pg_read_all_data) account in PostgreSQL and connecting only with that account. Even if the code-level filter is bypassed, data corruption cannot occur if the DB account itself has no write permissions. Similarly, theALLOWED_TABLESlist is valid fordescribe_tableinput validation, but it does not prevent querying other tables viaquery_metrics. If you want to expose only specific tables, the reliable approach is granting SELECT permission only on those specific tables and views at the DB level.
Now register it in the Hermes config.yaml:
# hermes config.yaml
mcp_servers:
internal_db:
command: "pnpm"
args: ["exec", "tsx", "/path/to/internal-db-mcp/src/server.ts"]
env:
DB_URL: "postgresql://readonly_user:pass@db.internal:5432/prod"
allowed_tools:
- "query_metrics"
- "list_tables"
- "describe_table"Using Official Servers: Connecting Grafana + Prometheus for Monitoring
For monitoring systems, using official or community servers is better than building your own. Combine the official Grafana MCP server with the community Prometheus server.
PromQL in one sentence: The query language for retrieving metrics from Prometheus. It aggregates time-series data with time ranges like
rate(http_requests_total[5m])— similar to SQL but specialized for time-series.
# hermes config.yaml — adding monitoring servers
mcp_servers:
internal_db:
command: "pnpm"
args: ["exec", "tsx", "/path/to/internal-db-mcp/src/server.ts"]
env:
DB_URL: "postgresql://readonly_user:pass@db.internal:5432/prod"
allowed_tools:
- "query_metrics"
- "list_tables"
- "describe_table"
grafana:
command: "npx"
args:
- "@grafana/mcp-grafana"
- "--grafana-url"
- "http://grafana.internal:3000"
- "--api-key"
- "${GRAFANA_API_KEY}"
# Note: ${...} environment variable interpolation only works if the Hermes config parser supports it.
# If not supported, replace with GRAFANA_API_KEY specified directly in an env: block.
allowed_tools:
- "list_dashboards"
- "get_dashboard"
- "query_datasource"
- "list_alerts"
prometheus:
command: "python"
args: ["-m", "prometheus_mcp_server"]
# Community package. GitHub: github.com/pab1it0/prometheus-mcp-server
env:
PROMETHEUS_URL: "http://prometheus.internal:9090"How to verify registered tools after configuration:
hermes mcp list
# Example output
internal_db:
- query_metrics: Execute SQL query on internal database
- list_tables: List all available tables
- describe_table: Show table schema
grafana:
- list_dashboards: List all Grafana dashboards
- get_dashboard: Get dashboard details
- query_datasource: Execute query against a data source
- list_alerts: List active alerts
prometheus:
- query_range: Execute PromQL range query
- query_instant: Execute instant PromQL queryWith this configuration in place, you can ask Hermes things like:
"Show me which services had API response times exceeding 500ms in the last hour,
and also tell me about the DB query error status for those services."Hermes will automatically run PromQL against Prometheus, query the internal DB based on the results, and deliver an integrated analysis. I was genuinely surprised when I first saw this work in practice. Our team still uses this exact configuration for on-call situations today, and the time to identify incident root causes has noticeably been cut nearly in half.
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| Standardized reuse | An MCP server built once can be reused across all compatible clients — Hermes, Claude Code, Cursor, etc. |
| Security boundary separation | The LLM never accesses the DB directly; the MCP server controls permissions and queries as an intermediary |
| Real-time data | Directly queries the latest data from DBs and monitoring systems without embeddings or RAG |
| Tool filtering | allowed_tools configuration gives fine-grained control over agent permissions |
| Language independence | Servers can be implemented in TypeScript, Python, C#, or whatever language your team knows |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Production immaturity | 86% of all MCP servers are running in developer local environments | Consider Streamable HTTP + gateway configuration for shared team servers |
| stdio visibility gap | stdio mode makes audit logging and central monitoring difficult | Log every tool invocation via a dedicated logging middleware |
| Response latency | Real-time tool calls can have higher latency than RAG-based approaches | Consider caching results for frequently used queries |
| OAuth token management | Automatic token refresh for agents running overnight is complex | Use service account tokens or long-lived API keys |
The Most Common Mistakes in Practice
-
Using production DB credentials as-is. I want to be emphatic here because this is something I've been burned by — agents unexpectedly firing off long-running queries or accessing tables you didn't anticipate happens more often than you'd think. Creating a separate read-only account and assigning it exclusively to the MCP server is a step that's easy to skip but absolutely must not be.
-
Omitting
allowed_toolsor leaving it as a wildcard. Opening all tools a MCP server provides to the agent means that if an administrative tool is later added to the server, it automatically becomes accessible too. Explicitly listing only the tools you actually need is far safer. -
Not logging MCP tool call activity. If you can't track which queries the agent ran and how often, incident root cause analysis becomes very difficult. For custom MCP servers, adding middleware that logs every tool invocation to a central logging system will pay dividends later.
Closing Thoughts
Getting Hermes Agent to handle your internal DB and monitoring in natural language with a single MCP server is much faster to start than you'd think.
Three steps you can take right now:
-
Run a custom MCP server locally — Copy the TypeScript example above, swap
DB_URLto your development DB, and runpnpm exec tsx src/server.ts. Once the server is up, you can verify the MCP handshake works by sending this initialization JSON via stdin:json{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}If you get "Connection refused," the first things to check are a typo in DB_URL or port configuration.
-
Register it in the Hermes
config.yamland runhermes mcp list— If the tool list prints correctly, the handshake succeeded. If Hermes can't find the server, check whether the path incommandis an absolute path and whether it has execute permissions. Once you reach this step, Hermes is ready to query your DB in natural language. -
Add the official Grafana MCP server — It installs with a single
npx @grafana/mcp-grafanacommand, and with just an API key and URL, the agent can immediately query dashboards and alert data. You'll feel the real power of combining both servers right away.
Even as I write this, Hermes is digging through production metrics in another tab. The middle-of-the-night on-call shifts still come, but the time spent on "identifying the cause" has definitely gone down.
References
- Model Context Protocol Official Docs — Build an MCP Server
- Hermes Agent Official Docs — MCP Feature
- Use MCP with Hermes — Official Guide
- Hermes Agent MCP Integration Complete Guide | Lushbinary
- NousResearch/hermes-agent | GitHub
- Postgres MCP Pro | crystaldba GitHub
- MCP Toolbox for Databases | Google Cloud Blog
- grafana/mcp-grafana | GitHub
- Monitor MCP Servers with Prometheus & Grafana | Grafana Labs
- MCP TypeScript SDK | GitHub
- How to Build a Custom MCP Server with TypeScript | freeCodeCamp
- Chapter 13: MCP Integration — Claude Code vs. Hermes Agent | Ken Huang