The Problem
Theory is done. This lesson builds production-grade MCP servers for three tools that appear in nearly every company's stack: GitHub for code and issues, Slack for team communication, and Postgres for business data.
Each integration has sharp edges. GitHub's API requires a Personal Access Token and returns paginated results in nested JSON. Slack enforces per-method rate limits and has message timestamps as first-class identifiers. Postgres is a live database where a model-generated SELECT * on a 5-million-row table without a LIMIT will OOM your server, and a DROP TABLE on the wrong prompt injection would be catastrophic.
The right approach isn't to hope the model behaves. Safety goes in the tool layer: hardcoded row limits, read-only database users, blocked DDL operations, explicit write confirmation. Defense in depth means the system is safe even when the model does something unexpected.
Principle of least privilege for AI tools
Each MCP server connection should have only the permissions required for its intended operations. The read query tool uses a database user with only SELECT. The write tool uses a separate user. Even if the model is deceived into trying a DROP TABLE, the connection lacks permission to execute it.
GitHub MCP server
The GitHub REST API is well-documented and returns structured JSON. Authentication: Authorization: Bearer <PAT> header. Your PAT only needs the scopes required for your tools — repo:read and issues:write covers the core use cases.
What to expose:
search_repositories — find projects, check ecosystem healthget_issue — full issue details by numberlist_issues — browse open issues by label, statecreate_issue_comment — post AI-generated analysis or summarieslist_pull_requests — track open PRssearch_code — find examples, check usage patterns
What NOT to expose: delete_repository, force_push, delete_branch. Destructive operations should not be AI-callable tools. If a user needs them, they use the GitHub UI directly.
Return schemas should be stable and minimal: repos return {id, full_name, description, stars, language, url}. Issues return {number, title, body, state, labels, assignees, url}. Consistent schemas make the model's output predictable.
Your Postgres MCP server's execute_query tool should enforce a row limit (e.g., LIMIT 1000) because:
Models don't know your table sizes. "Show me all customer records" on a 5M-row table without a limit causes a multi-GB result set, OOM errors, and a broken context window. The MCP server must enforce safety limits as a hard constraint — not hope the model will add LIMIT itself. Defense in depth: safety at the tool layer, not just in the prompt.
Slack MCP server
Authentication: Slack Bot Token (xoxb- prefix). Create a Slack app, install it to your workspace, and copy the Bot Token. The bot can only see channels it has been added to.
Rate limits are the main operational concern. Slack's Web API enforces per-method rate limits (Tier 1: 1 req/min, Tier 2: 20 req/min, Tier 3: 50 req/min). When rate limited, the API returns {"ok": false, "error": "ratelimited"}. The correct response is exponential backoff — wait 1s, then 2s, then 4s before retrying.
Always include ts (timestamp) in message results. Slack uses ts as the message identifier for threading, reactions, and precise references. The model needs ts to reply in threads or reference specific messages.
Key limitation: search.messages only returns the past 60 days on free workspaces. Document this in the tool description so the model doesn't search for old messages expecting results.
Postgres MCP server
Postgres is the highest-stakes integration — it's a live database with real data. Five safety layers, all enforced in the tool code, not the system prompt:
- Parameterized queries — pass values as parameters, never interpolate into SQL strings.
cursor.execute("SELECT * FROM users WHERE email = %s", [email]) not cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
- Read-only user for
execute_query — the database user for SELECT operations has only SELECT permission. Even if the model generates DELETE FROM users, the connection can't execute it.
- Separate write user for
execute_write — mutations use a different database user. Segmenting read and write at the DB level is cleaner than relying on query parsing.
- Explicit confirmation —
execute_write requires confirmed: true in the arguments. The model must explicitly set this to execute a write. This forces an intentional step and prevents accidental modifications.
- Blocked DDL keywords —
DROP, TRUNCATE, CREATE, ALTER, GRANT, REVOKE are blocked at the tool level before any database connection is made. The check happens in Python, not the database.
Why should your Postgres MCP server use a read-only database user for the execute_query tool?
This is the principle of least privilege applied to AI tools. The model may be deceived via prompt injection into trying to DROP TABLE or DELETE FROM. If the MCP server's read connection uses a database user with only SELECT permissions, the attack fails at the infrastructure layer regardless of what SQL the model generates. Security isn't just in the query validator — it's in the permissions.
Build
Build it
Here's the defense-in-depth check in miniature: a query gets inspected for blocked keywords and confirmation requirements before it's allowed to "run."
BLOCKED_KEYWORDS = {"DROP", "TRUNCATE", "CREATE", "ALTER", "GRANT", "REVOKE"}
def check_query(sql, confirmed):
upper_sql = sql.upper()
for keyword in BLOCKED_KEYWORDS:
if keyword in upper_sql:
return "BLOCKED", f"DDL keyword '{keyword}' is not allowed"
is_write = any(w in upper_sql for w in ("DELETE", "UPDATE", "INSERT"))
if is_write and not confirmed:
return "REJECTED", "Write operation requires confirmed=true"
return "ALLOWED", "Query passed safety checks"
test_queries = [
("SELECT * FROM customers LIMIT 1000", False),
("DROP TABLE customers", True),
("DELETE FROM customers WHERE id = 5", False),
("DELETE FROM customers WHERE id = 5", True),
]
for sql, confirmed in test_queries:
status, reason = check_query(sql, confirmed)
print(f"[{status:9}] confirmed={confirmed!s:5} | {sql!r} -> {reason}")
The code artifact lesson_05_05_real_integrations.py implements:
GitHubMCPServer — 6 tools with PAT auth, structured return schemas, mock mode for demos without credentialsSlackMCPServer — 5 tools with Bot Token auth, exponential backoff retry logicPostgresMCPServer — 5 tools with read/write user separation, parameterized queries enforced, DDL blocking, row limit auto-addition- Cross-system integration test — GitHub issues → Slack post → Postgres log
- Production security checklist for all three servers
Set your credentials:
export GITHUB_TOKEN=ghp_...
export SLACK_BOT_TOKEN=xoxb-...
export DATABASE_URL=postgresql://readonly:pass@localhost/mydb
Run it:
pip install mcp anthropic psycopg2-binary
python lesson_05_05_real_integrations.py