What is MCP Adapter?
MCP Adapter is an AI design pattern for LLM apps and agents, in the Tools and action group. Expose systems as MCP servers so any agent uses the same contract.
Adaptador MCP
MCP Adapter is an AI design pattern for LLM apps and agents, in the Tools and action group. Expose systems as MCP servers so any agent uses the same contract.
Expose internal systems as MCP (Model Context Protocol) servers, so any agent uses the same tool contract. It is for companies with several agents, clients, and models that need to reach the same systems.
A standard wall socket: any appliance with the right plug works, with no rewiring.
Vitor works on the platform team at a company with four agents in production: support, back-office, engineering, and an internal assistant. All four need to query the ERP, the CRM, and the ticket system.
Each team wrote its own tools. The ERP has four implementations of “find order”, with different names, parameters, and return formats. One returns the date as 2026-09-30, another as 30/09/2026, and a third returns the whole ERP object, with 80 fields.
When the ERP’s API changed its authentication, four teams had to fix their code the same week. When the company wanted to try a coding assistant that already speaks MCP, nobody could connect the ERP to it without writing the integration from scratch.
And nobody could answer a simple audit question: which agents can create returns, and on whose behalf?
The problem is not any single tool. It is the lack of a common contract between the systems and whoever uses them.
The question this pattern answers: how do you write each integration once and use it from any agent, model, or client?
For each system, build an MCP server that exposes tools (actions), resources (data to read), and, where useful, ready-made prompts. The server owns authentication, permissions, validation, and output format. Agents connect as MCP clients and receive the tool list with name, description, and schema.
MCP is an open protocol, released by Anthropic in November 2024 and later adopted by other providers and tools. The same server works with your own agents, with coding assistants, with IDEs, and with model APIs’ MCP connectors. The integration stops belonging to an agent and starts belonging to the system.
Count the integrations. With 4 agents and 3 systems, each team writing its own, you have up to 12 integrations to maintain. With one server per system, you have 3 servers, and each new agent only needs an MCP client, which the framework usually ships.
MCP uses JSON-RPC messages. When the support agent connects to the ERP server, the client asks for the tool list (tools/list) and gets something like this (trimmed):
{
"tools": [
{
"name": "find_order",
"description": "Gets an order's status, items, and dates. Use it when the user mentions an order number.",
"inputSchema": {
"type": "object",
"properties": { "order_id": { "type": "string", "pattern": "^[0-9]+$" } },
"required": ["order_id"]
}
}
]
}
The client hands that list to the model as tool definitions. When the model decides to use find_order, the client sends a tools/call:
{ "method": "tools/call", "params": { "name": "find_order", "arguments": { "order_id": "88231" } } }
The server checks the user’s scope, calls the ERP, and returns only what matters:
{
"content": [
{ "type": "text", "text": "{\"id\": \"88231\", \"status\": \"delivered\", \"delivered_at\": \"2026-09-30\", \"items\": [{\"sku\": \"CAD-112\", \"qty\": 2}]}" }
],
"isError": false
}
Notice what the agent does not see: the ERP’s URL, the service token, the 80 raw fields. All four agents get the same answer, in the same format, with the same date.
flowchart LR
A1(["1. Support agent"]):::llm --> C["2. MCP client"]:::tool
A2(["1. Back-office agent"]):::llm --> C
A3(["1. Coding assistant"]):::llm --> C
C --> S1["3. ERP MCP server"]:::tool
C --> S2["3. CRM MCP server"]:::tool
S1 --> P{"4. Auth and permissions"}
P --> ERP[("5. ERP")]:::store
S2 --> CRM[("5. CRM")]:::store
server erp_mcp:
tool find_order(order_id: string) -> Order # read
require scope("orders:read")
return normalize(erp.get_order(order_id))
tool create_return(order_id: string, reason: string) -> ReturnLabel # side effect
require scope("returns:write")
audit.log("create_return", caller, order_id)
return erp.create_return(order_id, reason, idempotency_key=request_id())
resource "erp://policies/returns" -> text # data to read
agent support:
tools = mcp.connect("erp_mcp", token=user_token).list_tools() # 2
run_agent(SUPPORT_PROMPT, tools) # 1
find_order, not GET /v2/sales/orders/{id}/header.order_id to orderId in a deploy breaks every agent at once. Add fields; to remove or rename, publish a new version.| Variation | Where it runs | Good for | Watch out for |
|---|---|---|---|
| Local (stdio) | the agent’s machine | developer tools | credentials on each person’s machine |
| Remote (HTTP) | shared service | production, several agents | availability and authentication |
| API connector | the provider calls your server | agents without their own loop | the server must be reachable by the provider |
| Gateway | in front of the servers | central policy and auditing | one more hop and one more point of failure |
| Aspect | What happens | How it shows up in the trace |
|---|---|---|
| Calls | Each tool becomes a request to the server | mcp.call spans with server and tool |
| Latency | One more network hop per remote call | mcp.call span duration |
| Context | The server’s tool list goes into the prompt | tool tokens per connection |
| Failure: server down | Every agent that depends on it stops | mcp.call errors from several agents at once |
| Failure: schema changed | Old agents send invalid arguments | validation errors after a server deploy |
| Failure: too many tools | A server exposes dozens of operations | wrong tool choices in the agent |
The context cost is easy to forget. Connecting three servers with 20 tools each puts 60 definitions in every agent call. Expose only what each agent needs, or combine this with the Tool Router.
{ "agent": "support", "task": "status of order 88231", "expected_tool": "find_order", "expected_scope": "orders:read" }
Contract tests need no model. Call the server directly with a test MCP client: an order_id with letters must be rejected, a user without returns:write cannot create a return, and find_order always returns the same fields. These tests run on every server deploy, before any agent feels the change.
The MCP server is the security boundary. It authenticates the caller, applies least privilege per tool, and never hands credentials to the model.
Third-party servers are third-party code: review them before connecting. Tool descriptions go straight into the model’s context, so a malicious description works as prompt injection. In 2025, researchers at Invariant Labs demonstrated this attack under the name “tool poisoning”: instructions hidden in one tool’s description led the agent to leak data from another.
Results are untrusted content too. A public issue, an email, or a page read by a tool can carry instructions for the agent. That is the indirect injection described by Greshake et al. (2023). See Untrusted Instructions and Narrow Tool Permissions.
| Pros | Cons |
|---|---|
| One integration per system, used by everyone | One more service to operate |
| Switch model or client without rewriting tools | One more network hop |
| Centralized permissions and auditing | A server outage takes down several agents |
| Works with third-party tools that speak MCP | Third-party servers require security review |
Both tabs build an MCP server with one read tool and one side-effect tool, separated by scope.
Python. Use this when the platform team writes servers in Python with the official MCP SDK.
from mcp.server.fastmcp import FastMCP
mcp = FastMCP("erp") # 3. one server per system
@mcp.tool()
def find_order(order_id: str) -> dict:
"""Gets an order's status, items, and dates. Use it when the user mentions an order number."""
require_scope("orders:read") # 4. permission of the calling user
order = erp_client.get_order(order_id)
return { # 5. useful fields only, stable format
"id": order.id,
"status": order.status,
"delivered_at": order.delivered_at.isoformat() if order.delivered_at else None,
"items": [{"sku": i.sku, "qty": i.qty} for i in order.items],
}
@mcp.tool()
def create_return(order_id: str, reason: str) -> dict:
"""Creates a return for a delivered order. Only use it after the customer confirms the return."""
require_scope("returns:write")
audit_log("create_return", order_id=order_id, reason=reason)
label = erp_client.create_return(order_id, reason, idempotency_key=current_request_id())
return {"return_id": label.id, "label_url": label.url}
@mcp.resource("erp://policies/returns")
def returns_policy() -> str:
"""Current exchange and return policy."""
return erp_client.get_policy("returns")
if __name__ == "__main__":
mcp.run() # stdio by default; for a shared server, mcp.run(transport="streamable-http")What you gained: any MCP client can now use the ERP with the same contract and permissions. What you lost: require_scope and user identity depend on the transport and your authentication; settle that before exposing the server.
TypeScript. Use this when the server runs on Node with the official MCP TypeScript SDK.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
const server = new McpServer({ name: "erp", version: "1.4.0" });
server.registerTool(
"find_order",
{
description: "Gets an order's status, items, and dates. Use it when the user mentions an order number.",
inputSchema: { order_id: z.string().regex(/^\d+$/) },
},
async ({ order_id }) => {
requireScope("orders:read"); // 4
const o = await erp.getOrder(order_id);
const data = { id: o.id, status: o.status, delivered_at: o.deliveredAt, items: o.items.map((i) => ({ sku: i.sku, qty: i.qty })) };
return { content: [{ type: "text", text: JSON.stringify(data) }] }; // 5
},
);
server.registerTool(
"create_return",
{
description: "Creates a return for a delivered order. Only use it after the customer confirms the return.",
inputSchema: { order_id: z.string().regex(/^\d+$/), reason: z.string().max(500) },
},
async ({ order_id, reason }) => {
requireScope("returns:write");
await auditLog("create_return", { order_id, reason });
const label = await erp.createReturn(order_id, reason, { idempotencyKey: currentRequestId() });
return { content: [{ type: "text", text: JSON.stringify({ return_id: label.id, label_url: label.url }) }] };
},
);
await server.connect(new StdioServerTransport());What you gained: the Zod schema validates input on the server, for every client. What you lost: same as Python; authentication and scopes must be wired to the chosen transport.
MCP Adapter is an AI design pattern for LLM apps and agents, in the Tools and action group. Expose systems as MCP servers so any agent uses the same contract.
In the real-world scenarios Coding agent, Back-office automation with durable execution, and Internal knowledge base chat.