MCP: giving LLMs access to your systems without giving away the keys
The Model Context Protocol lets LLMs query Frappe doctypes, search internal wikis, and call APIs -- with scoped permissions and full audit trails.
MCP: Giving LLMs Access to Your Systems Without Giving Away the Keys
We were building an AI assistant for a client's operations team. The assistant needed to answer questions like "What's the status of PO-2024-1847?" and "Show me all overdue deliveries in the Mumbai warehouse." The data lived in their Frappe-based ERP. The question was: how do you let an LLM query live business data without giving it raw database access?
The answer is the Model Context Protocol. MCP defines a standard way for LLMs to discover and use tools -- APIs, databases, file systems, internal services -- with scoped permissions and structured interfaces. Instead of dumping your database schema into a prompt, you expose specific, permission-controlled operations that the LLM can call.
The problem
Connecting LLMs to internal systems is the gap between "impressive demo" and "useful product." A chatbot that answers from a static knowledge base is limited. A chatbot that can query live data, trigger workflows, and look up records is transformative. But the naive approach -- giving the LLM a database connection or an admin API key -- is a security and reliability nightmare.
What you need:
- Scoped access -- The LLM can read purchase orders but not modify them. It can search inventory but not delete records.
- Structured interfaces -- The LLM calls defined operations with typed parameters, not raw SQL.
- Audit trail -- Every tool call is logged: who asked, what was called, what was returned.
- Discoverability -- The LLM knows what tools are available and what parameters they accept, through a standard protocol.
How MCP works
MCP is a client-server protocol. The LLM (via a host application like Claude Desktop or a custom agent) is the client. Your system exposes an MCP server that declares its available tools.
LLM Host (Claude, custom agent)
|
v
MCP Client (discovers and calls tools)
|
v
MCP Server (your code -- exposes tools with schemas)
|
v
Your Systems (Frappe, PostgreSQL, internal APIs)
The MCP server declares tools with JSON Schema descriptions. The LLM sees these descriptions, decides which tools to call based on the user's question, and sends structured requests. The server executes the request against your systems and returns structured results.
Building an MCP server for Frappe
We built an MCP server that exposes Frappe DocType operations as LLM-callable tools. Here's the core pattern:
from mcp.server import Server
from mcp.types import Tool, TextContent
import frappe
server = Server("frappe-erp")
@server.list_tools()
async def list_tools():
return [
Tool(
name="get_purchase_order",
description="Look up a purchase order by ID. Returns status, items, supplier, and amounts.",
inputSchema={
"type": "object",
"properties": {
"order_id": {
"type": "string",
"description": "Purchase order ID (e.g., PO-2024-1847)"
}
},
"required": ["order_id"]
}
),
Tool(
name="search_inventory",
description="Search inventory items by warehouse and status. Returns item code, quantity, and location.",
inputSchema={
"type": "object",
"properties": {
"warehouse": {"type": "string", "description": "Warehouse name"},
"status": {"type": "string", "enum": ["In Stock", "Reserved", "In Transit"]},
},
"required": ["warehouse"]
}
),
Tool(
name="list_overdue_deliveries",
description="List delivery notes that are past their expected delivery date.",
inputSchema={
"type": "object",
"properties": {
"warehouse": {"type": "string", "description": "Filter by warehouse (optional)"},
"days_overdue": {"type": "integer", "description": "Minimum days overdue", "default": 1}
}
}
),
]
@server.call_tool()
async def call_tool(name: str, arguments: dict):
if name == "get_purchase_order":
po = frappe.get_doc("Purchase Order", arguments["order_id"])
return [TextContent(
type="text",
text=f"PO: {po.name}\nSupplier: {po.supplier_name}\n"
f"Status: {po.status}\nTotal: {po.grand_total} {po.currency}\n"
f"Items: {len(po.items)}\nExpected: {po.schedule_date}"
)]
if name == "search_inventory":
filters = {"warehouse": arguments["warehouse"]}
if "status" in arguments:
filters["status"] = arguments["status"]
bins = frappe.get_list("Bin", filters=filters,
fields=["item_code", "actual_qty", "warehouse"],
limit=20)
lines = [f"{b['item_code']}: {b['actual_qty']} units @ {b['warehouse']}" for b in bins]
return [TextContent(type="text", text="\n".join(lines) or "No items found.")]
# ... similar for list_overdue_deliveries
The key design decisions:
- Read-only tools. The MCP server exposes lookups and searches. It does not expose create, update, or delete operations. Write operations go through the normal Frappe UI with human approval.
- Frappe's permission model applies. The MCP server runs as a specific Frappe user with a defined role.
frappe.get_docrespects that user's permissions. If the role can't access a DocType, neither can the LLM. - Structured, bounded responses. Each tool returns formatted text with specific fields, not raw JSON dumps. Limits prevent returning 10,000 records for a broad query.
What the user experience looks like
An operations manager opens the AI assistant and asks:
"What's the status of PO-2024-1847 and are there any overdue deliveries in Mumbai?"
The LLM:
- Recognizes this requires two tool calls
- Calls
get_purchase_orderwith{"order_id": "PO-2024-1847"} - Calls
list_overdue_deliverieswith{"warehouse": "Mumbai"} - Composes a natural language response from both results
The response:
"PO-2024-1847 is currently in 'To Receive and Bill' status from Supplier ABC Corp, total INR 4,50,000 with 12 items expected by March 15. There are 3 overdue deliveries in the Mumbai warehouse: DN-2024-0892 (2 days), DN-2024-0901 (1 day), and DN-2024-0915 (4 days overdue)."
Every tool call is logged with the user's identity, timestamp, arguments, and response. The audit trail is complete.
In production
We've deployed MCP servers for two Frappe-based systems. Lessons:
- Tool descriptions are the prompt engineering. The LLM decides which tool to call based on the
descriptionfield. Vague descriptions cause wrong tool selection. Specific descriptions ("Look up a purchase order by ID") work. Generic descriptions ("Get data from the ERP") don't. - Limit the tool count. We expose 8-12 tools per MCP server. More tools increase the LLM's decision surface and cause more mis-selections. Group related operations logically.
- Error handling in tool responses. When a purchase order doesn't exist, return "No purchase order found with ID PO-2024-XXXX" -- not a Python traceback. The LLM uses your error message to respond to the user.
- Latency matters. Each tool call adds 200-500ms of Frappe query time on top of the LLM's response time. Keep tool implementations fast. Pre-filter in the query, not in Python.
The tradeoffs
- Read-only limitation. We deliberately exclude write operations. An LLM that can modify business data through natural language is a liability until the trust model matures. For now, the AI reads and reports; humans approve and act.
- Tool schema maintenance. When the Frappe DocType changes (a field is renamed or a new status is added), the MCP tool schema must be updated. We version the MCP server alongside the Frappe app.
- LLM reasoning errors. The LLM sometimes calls the wrong tool or passes incorrect parameters. Our MCP server validates all inputs against the schema and returns clear error messages. Rate limiting prevents runaway tool call loops.
Our recommendation
If you're building AI assistants that need access to live business data, MCP is the right integration pattern. It gives the LLM structured, scoped, auditable access to your systems without embedding SQL in prompts or granting API keys with broad permissions.
Start with read-only tools for your most-queried data (order status, inventory levels, delivery tracking). Deploy the MCP server with the same user permissions model you'd give a junior analyst. Log everything. Expand the tool set based on what users actually ask for -- you'll find that 8-10 well-designed tools cover 90% of queries.
The protocol is open. The server runs on your infrastructure. The audit trail is yours. Your data stays where it belongs.