OpenCode's MCP support is solid but younger than Claude Code's, and the gaps show up specifically around long-running tool calls and how much plan-tree output it's willing to keep in context across turns. This topic covers wiring postgres-mcp into OpenCode's config, the schema/index exploration workflow, a full migration-script example, and the specific limitations worth knowing before you lean on it for anything production-critical.
Installing and Connecting Postgres MCP to OpenCode
OpenCode reads MCP server definitions from opencode.json (project root) or the global ~/.config/opencode/opencode.json. Project-level config wins for anything with a real connection string:
{
"$schema": "https://opencode.ai/config.json",
"mcp": {
"postgres": {
"type": "local",
"command": [
"uvx",
"postgres-mcp",
"--access-mode=restricted",
"{env:POSTGRES_MCP_URI}"
],
"enabled": true
}
}
}
{env:VAR} interpolation pulls from the shell environment at launch time, so the actual connection string never touches the checked-in config file:
export POSTGRES_MCP_URI="postgresql://mcp_agent:CHANGE_ME@localhost:5432/app_dev"
opencode
Confirm it loaded from inside a session with /mcp:
/mcp
postgres: connected (3 tools: execute_sql, list_objects, get_object_details ...)
If the server type or path is wrong, /mcp usually shows postgres: error with the stderr tail from the failed uvx launch — check that uv/uvx is actually on the PATH OpenCode inherits, which on some macOS setups differs from your interactive shell's PATH if OpenCode was launched from a GUI app launcher rather than a terminal.
which uvx
uvx postgres-mcp --version
Tips
- Use{env:VAR}interpolation inopencode.json, not a literal connection string — the file is meant to be committed.
-/mcpinside a running session is the fastest way to confirm a server is actually connected versus just declared in config.
- If OpenCode was launched from Finder/Spotlight rather than a terminal on macOS, it may not inherit your shell'sPATH— launch from a terminal during setup to rule that out as a failure cause.
Querying Schemas, Tables, and Indexes from OpenCode
The exploration workflow is the same tool set as any other client, but OpenCode's default context window handling means you get better results being more surgical about what you ask for in one turn versus Claude Code, which tolerates broader "map everything" requests more gracefully.
Prompt:
List tables in the public schema via postgres MCP. Then, for the
`subscriptions` table specifically, show me its columns, all indexes,
and any foreign keys — I need to know if there's already an index
covering (customer_id, status) before I add one.
Tool: list_objects { "schema_name": "public", "object_type": "table" }
Tool: get_object_details { "schema_name": "public", "object_type": "table", "object_name": "subscriptions" }
Typical output for the index check:
Indexes on subscriptions:
subscriptions_pkey UNIQUE btree (id)
idx_subscriptions_customer_id btree (customer_id)
idx_subscriptions_status btree (status)
No composite index on (customer_id, status) exists. Two single-column
indexes exist separately; Postgres can bitmap-AND them for a query
filtering on both, but a composite index would be more efficient for
this specific access pattern if it's frequent.
That's a correct and useful answer, but it's also exactly the kind of thing worth double-checking with explain_query on the actual query you care about rather than accepting the general index-theory statement — bitmap AND of two single-column indexes is often close enough in practice that a new composite index isn't worth the write-amplification cost. Ask the agent to prove it with a real plan, not just describe the general principle.
EXPLAIN ANALYZE
SELECT * FROM subscriptions WHERE customer_id = 4821 AND status = 'active';
Tips
- Scope schema questions to specific tables in OpenCode rather than "map the whole database" — the smaller the ask, the more reliably the tool-call loop completes in one pass.
- Push past a general index-theory answer to an actualEXPLAIN ANALYZEon your real query before deciding a composite index is worth adding.
-get_object_detailsoutput format is stable across thecrystaldba/postgres-mcpversions I've tested (0.2.x-0.3.x), so prompts written against it don't need much rework across upgrades.
Practical Example: Generating a Data Migration Script in OpenCode
A realistic ask: backfilling a new column from existing data, safely, on a table too large for a naive single-transaction UPDATE.
Prompt:
I added a nullable `region` column to the `customers` table (varchar).
I want to backfill it from `customers.billing_country` using this
mapping: US/CA -> "NA", GB/DE/FR/ES/IT -> "EU", everything else ->
"OTHER". The table has ~6M rows. Check the table size and current
load via postgres MCP, then write a batched backfill script safe to
run against production, plus the down migration.
The agent first checks scale before proposing an approach:
SELECT count(*) FROM customers; -- 6,142,003
SELECT pg_size_pretty(pg_total_relation_size('customers')); -- 3812 MB
Then a batched backfill using ctid-range or PK-range chunking rather than a single UPDATE ... WHERE region IS NULL, which would hold row locks across the whole table for the transaction's duration:
-- migration: 0047_backfill_customer_region.sql
-- Batched backfill, 5,000 rows per transaction, safe to run against
-- a live production table. Re-runnable: only touches rows where
-- region IS NULL, so a partial run can simply be resumed.
DO $$
DECLARE
rows_updated integer;
BEGIN
LOOP
WITH batch AS (
SELECT id FROM customers
WHERE region IS NULL
ORDER BY id
LIMIT 5000
FOR UPDATE SKIP LOCKED
)
UPDATE customers c
SET region = CASE
WHEN c.billing_country IN ('US', 'CA') THEN 'NA'
WHEN c.billing_country IN ('GB', 'DE', 'FR', 'ES', 'IT') THEN 'EU'
ELSE 'OTHER'
END
FROM batch
WHERE c.id = batch.id;
GET DIAGNOSTICS rows_updated = ROW_COUNT;
EXIT WHEN rows_updated = 0;
COMMIT;
PERFORM pg_sleep(0.1); -- brief pause between batches to ease replication lag
END LOOP;
END $$;
-- down migration: 0047_backfill_customer_region_down.sql
UPDATE customers SET region = NULL WHERE region IS NOT NULL;
The FOR UPDATE SKIP LOCKED plus explicit COMMIT inside the loop and a small pg_sleep between batches are the parts that actually matter for a 6M-row table on a live system — they keep any single transaction short and avoid piling up replication lag on a read replica. An agent asked generically to "write a migration" without being told the table's real size will often skip straight to the single-statement UPDATE, which is why running the row-count and size check first, and telling it to, matters.
Tips
- Always have the agent check actual table size/row count via MCP before it chooses a migration strategy — a single-statementUPDATEis fine for 10K rows and dangerous for 6M.
-FOR UPDATE SKIP LOCKEDplus batching in explicit transactions is the pattern to ask for by name if the agent doesn't propose it on its own for a large-table backfill.
- Always ask for the down migration in the same response — it's easy to get the forward script and forget the reverse until you actually need to roll back at 2am.
Known Limitations for Postgres MCP in OpenCode
A few things worth knowing going in, from actually running this combination:
- Long
EXPLAIN ANALYZEoutput on complex plans gets truncated more aggressively than in Claude Code's CLI. For a plan tree with 15+ nodes (common on a query joining 5+ tables), OpenCode sometimes shows only the top-level nodes in the rendered tool-call summary even though the full JSON was returned to the model. If the agent's diagnosis seems to be missing a nested sub-plan, ask it to paste the raw JSON plan directly rather than relying on the tool-call summary view. - No built-in equivalent to Claude Code's
--scope projectvs--scope usersplit. OpenCode's config precedence (projectopencode.jsonoverrides global) achieves the same practical outcome, but there's no CLI subcommand for adding/listing servers — you're editing JSON by hand, which is more error-prone for a team onboarding new members. - Session-to-session tool-call history isn't preserved the way Claude Code's
--resumecarries forward MCP context. Starting a new OpenCode session means the agent has forgotten what it learned about your schema last time; expect to re-run schema introspection at the start of each significant session rather than assuming persistent memory of your database structure. - Version churn. OpenCode's MCP client implementation has changed enough between recent releases that a config block working on one version occasionally needs a
type: "local"vstype: "stdio"key rename on upgrade — check the changelog if/mcpstarts showing a connected server as erroring after an OpenCode update.
None of these are blockers, but they mean OpenCode currently suits shorter, more targeted database sessions better than the long, exploratory multi-hour sessions Claude Code handles comfortably.
Tips
- For complex multi-joinEXPLAIN ANALYZEdiagnosis, ask for the raw JSON plan explicitly rather than trusting the summarized tool-call view.
- Re-run schema introspection at the start of each OpenCode session on a schema-heavy task — don't assume it remembers last session's findings.
- Pin yourpostgres-mcpserver version inopencode.json(or youruvxinvocation) if you hit a config-format break after an OpenCode upgrade, then upgrade both deliberately together.
Tips
Tips
-{env:VAR}interpolation inopencode.jsonkeeps connection strings out of version control while still letting the server config itself be committed and shared.
- Keep OpenCode database sessions scoped and shorter than you might in Claude Code — ask for specific tables and specific queries rather than open-ended schema mapping.
- For any migration script it drafts, insist on both the batched-safe forward migration and the down migration in the same response, and validate table scale via MCP before accepting either.