The pain
DataLayer ships data-source connectors for Postgres, Snowflake, Notion, Salesforce, and a dozen more. Originally each connector was a bespoke Python wrapper with hand-written tool schemas for the agent. Three problems compounded:
- 18% tool-call error rate. Agents kept passing malformed arguments.
- Three implementations per connector. Same connector code lived in their CLI, their hosted product, and their customer SDK.
- No standard error shape. Every connector raised its own exceptions.
Step 1 — Fix schemas before anything else
A representative *before* schema:
{
"name": "query",
"params": { "input": "string" }
}After:
{
"name": "run_sql",
"description": "Execute a read-only SQL query against the warehouse.",
"params": {
"sql": { "type": "string", "description": "Read-only SQL. SELECT/CTE/EXPLAIN only." },
"schema": { "type": "string", "default": "analytics", "description": "Postgres schema namespace" },
"row_limit": { "type": "integer", "default": 1000, "maximum": 10000 }
}
}Flat, intent-revealing field names, explicit defaults, enumerated bounds where applicable. Within two weeks the malformed-argument rate dropped from 18% to 5%.
Step 2 — Make errors teach the agent
The team standardized on a three-field error contract for every tool:
{
"error_code": "TABLE_NOT_FOUND",
"message": "Table 'order_events' does not exist in schema 'public'.",
"hint": "This workspace's tables live in schema 'analytics'. Try analytics.order_events."
}The biggest gain wasn't speed — it was that retries actually *succeeded*. Agents stopped looping on opaque "operation failed" strings and instead corrected themselves on the next call. The remaining 5% malformed-argument rate fell to 2%.
Step 3 — Move from bespoke connectors to MCP servers
Each connector became an MCP server. The same Postgres MCP server now exposes:
- Tools:
run_sql,describe_table,list_schemas - Resources:
postgres://table/{schema}/{name}for any table (cacheable client-side) - Prompts:
/explain-this-queryand/find-slow-queries— templates customers can surface in their own clients
The same server runs over stdio for local dev and over HTTPS for hosted. One implementation, three surfaces (CLI, desktop, customer SDK). New-connector dev time dropped from four weeks to six days because most of the work is now defining the schema and error map.
Step 4 — Resources, not tools, for big data
Their warehouse-metadata catalog has tens of thousands of tables. Exposing each as a tool call would burn schema tokens on every interaction. They expose them as resources:
postgres://table/analytics/order_events
postgres://table/analytics/users
…Clients cache resources locally. When an agent says "read analytics.order_events schema", the client serves it from its cache without round-tripping. Token cost on metadata operations dropped roughly 70%.
Step 5 — Test schemas and tools separately
The team now ships two test suites for every connector:
# 1. Tool function tests — no LLM.
def test_run_sql_returns_rows():
rows = run_sql({"sql": "SELECT 1", "schema": "analytics"})
assert rows == [{"?column?": 1}]
# 2. Schema probe — tiny Claude call, checks tool arg validity.
async def test_schema_yields_valid_args():
arg = await claude_probe("Read the first 5 rows of analytics.users")
RunSqlArgs.model_validate(arg) # raises if schema isn't communicatingWhen something breaks, you know whether it's the implementation or the schema description.
What stuck
- Schema design is the single biggest lever on tool-call reliability.
- A common error contract turns failures into recovery opportunities.
- MCP is what unlocks reuse across CLI / desktop / SDK with one implementation.
- Resources for read-only addressable data; tools for verbs.
- Two test layers: function correctness and schema communicability.
