I have a webhook that does two things: it looks up a company from an email domain, and it writes the lead to a sheet. It has worked for months and I had no reason to touch it. Then I wanted an agent to do the same two things on its own, mid-conversation, and the webhook stopped being the right shape. Not because it was slow or broken, but because a webhook is a door someone else opens, and what I needed was a menu something else could read.
So I rebuilt it as an MCP server, ran both versions on the same machine, and timed them. Below is the code, the real output, the errors I hit, and the rule I now use to decide which one a job gets. Everything in the code blocks is pasted from my terminal on 20 September 2026, on Python 3.14.6 with the official MCP Python SDK at version 2.2.0.
What the webhook already does
The job is unglamorous and probably looks like something you already run. A form posts a lead. Something enriches the domain. Something writes the row. In n8n that is three nodes: a Webhook trigger, a Code node that resolves the domain against a lookup, and a Google Sheets append.
Here is the workflow JSON, trimmed to the three nodes and their connections. I built this file and validated it with python -m json.tool, which passed. I did not run n8n on this machine, so treat the node parameters as the ones the n8n docs describe rather than a screenshot of my own canvas.
{
"name": "lead-desk-webhook",
"nodes": [
{
"parameters": {
"httpMethod": "POST",
"path": "lead",
"responseMode": "lastNode",
"options": {}
},
"name": "Webhook",
"type": "n8n-nodes-base.webhook",
"typeVersion": 2,
"position": [0, 0],
"webhookId": "lead"
},
{
"parameters": {
"jsCode": "const dir = { 'n8n.io': { name: 'n8n', ... } };\nconst d = String($json.body.domain || '').toLowerCase();\nreturn [{ json: dir[d] ? { domain: d, found: true, ...dir[d] } : { domain: d, found: false } }];"
},
"name": "Lookup Company",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [220, 0]
},
{
"parameters": {
"operation": "append",
"documentId": "={{ $env.LEAD_SHEET_ID }}",
"sheetName": "leads",
"columns": {
"mappingMode": "defineBelow",
"value": {
"email": "={{ $('Webhook').item.json.body.email }}",
"domain": "={{ $json.domain }}",
"stage": "={{ $('Webhook').item.json.body.stage }}"
}
}
},
"name": "Log Lead",
"type": "n8n-nodes-base.googleSheets",
"typeVersion": 4,
"position": [440, 0]
}
],
"connections": {
"Webhook": { "main": [[{ "node": "Lookup Company", "type": "main", "index": 0 }]] },
"Lookup Company": { "main": [[{ "node": "Log Lead", "type": "main", "index": 0 }]] }
},
"settings": { "executionOrder": "v1" }
}
Paste into n8n with Ctrl+V on the canvas. The Code node body is abbreviated here for width.
What that gives me is genuinely good, and I want to be fair to it before I start comparing. The n8n Webhook node docs (read 20 September 2026) list DELETE, GET, HEAD, PATCH, POST and PUT as supported methods, dynamic path segments in the /:variable form, four response modes including returning the last node's data or handing control to a Respond to Webhook node, and built in Basic auth, Header auth and JWT auth plus an IP allowlist. The docs also draw the line I forget every single time: the test URL shows incoming data in the editor, and the production URL only registers once the workflow is published.
That is a lot of infrastructure I get for free. Retries, credential storage, execution history, a visual log of every run. If you have not built this piece yet, my n8n webhook tutorial walks the trigger setup end to end, and the HTTP Request node guide covers the outbound half.
The one thing the webhook does not do is describe itself. Nothing about that URL tells a caller what it accepts. If I want a model to use it, I have to write the description by hand into a prompt, keep that description in sync with the workflow, and hope the model produces a body my Code node can read. That is the gap MCP fills.
The same two operations as an MCP server
I started a clean virtualenv and installed the official Python SDK. One package gives me both halves, server and client, so I did not need a second dependency to test it.
python3 -m venv .venv
./.venv/bin/pip install mcp
./.venv/bin/pip show mcp | head -3
Name: mcp
Version: 2.2.0
Summary: Model Context Protocol SDK
Then the server. Two functions, two decorators, one run(). This is the whole file.
"""Minimal MCP server: the two things my n8n lead webhook already does."""
import json, pathlib
from typing import Literal
from mcp.server.mcpserver import MCPServer
LEADS = pathlib.Path(__file__).with_name("leads.jsonl")
DIRECTORY = {
"voxdonna.com": {"name": "Voxdonna AI", "industry": "Voice AI",
"size": "1-10", "country": "IN"},
"n8n.io": {"name": "n8n", "industry": "Workflow automation",
"size": "51-200", "country": "DE"},
"anthropic.com": {"name": "Anthropic", "industry": "AI research",
"size": "501-1000", "country": "US"},
}
mcp = MCPServer("lead-desk")
@mcp.tool()
def lookup_company(domain: str) -> dict:
"""Look up a company by its web domain. Returns name, industry, size band and country."""
key = domain.strip().lower().removeprefix("www.")
if key not in DIRECTORY:
return {"domain": key, "found": False}
return {"domain": key, "found": True, **DIRECTORY[key]}
@mcp.tool()
def log_lead(email: str, domain: str, stage: Literal["new", "qualified", "dropped"]) -> dict:
"""Append a lead to the local ledger. Stage must be new, qualified or dropped."""
row = {"email": email, "domain": domain.strip().lower(), "stage": stage}
with LEADS.open("a") as f:
f.write(json.dumps(row) + "\n")
return {"written": True, "row": row, "total": sum(1 for _ in LEADS.open())}
if __name__ == "__main__":
mcp.run()
Two details in there are doing more work than they look like they are. The docstrings are not comments, they become the tool descriptions a model reads. And the type annotations are not decoration, they become the JSON Schema the client enforces. stage: Literal["new", "qualified", "dropped"] is the entire validation layer for that argument. I wrote no validation code.
The version trap I walked straight into
My first attempt at that import was from mcp.server.fastmcp import FastMCP, because that is what every tutorial I had read said. It failed, and the SDK's own error message told me why:
ModuleNotFoundError: No module named 'mcp.server.fastmcp'. This is mcp 2.x,
where FastMCP was renamed to MCPServer (from mcp.server.mcpserver import
MCPServer) and other APIs changed; see the migration guide at
https://py.sdk.modelcontextprotocol.io/v2/migration/#fastmcp-renamed-to-mcpserver
or pin 'mcp<2' to keep running v1 code.
That is the most helpful import error I have hit this year, and it saved me the search. The v1 to v2 migration guide (read 20 September 2026) confirms the rename and adds a second change that bit me on the client side a minute later: every Pydantic model field in the protocol types moved from camelCase to snake_case for Python attribute access. So inputSchema is now input_schema, isError is is_error, structuredContent is structured_content, mimeType is mime_type, and McpError is MCPError.
The wire format did not change. The JSON on the socket still says inputSchema. Only the Python attribute names moved. If you are following a tutorial written before this and it will not run, that is almost certainly why, and pinning mcp<2 is a legitimate answer if you are mid project.
What the agent sees before it calls anything
This is the part that has no webhook equivalent. Before any work happens, the client asks the server what it can do. In the spec that request is tools/list, and the reply is the full definition of every tool: name, description, and a JSON Schema for the inputs.
I wrote a small client that launches the server as a subprocess over stdio and prints exactly that. Here is the real reply, unedited:
--- list_tools ---
[
{
"name": "lookup_company",
"description": "Look up a company by its web domain. Returns name, industry, size band and country.",
"inputSchema": {
"properties": {
"domain": { "title": "Domain", "type": "string" }
},
"required": ["domain"],
"type": "object",
"title": "lookup_companyArguments"
}
},
{
"name": "log_lead",
"description": "Append a lead to the local ledger. Stage must be new, qualified or dropped.",
"inputSchema": {
"properties": {
"email": { "title": "Email", "type": "string" },
"domain": { "title": "Domain", "type": "string" },
"stage": {
"enum": ["new", "qualified", "dropped"],
"title": "Stage",
"type": "string"
}
},
"required": ["email", "domain", "stage"],
"type": "object",
"title": "log_leadArguments"
}
}
]
I did not write a word of that JSON. It came from two docstrings and six type annotations. The enum on stage came from the Literal. And because the model gets this list at connection time, adding a third tool to the server makes that tool available to the agent without touching the agent.
The MCP tools specification (read 20 September 2026) is explicit that this is the design intent: tools are model controlled, meaning the model can discover and invoke them based on its understanding of the conversation. It also specifies a listChanged capability so a server can notify a connected client that its tool set has changed, which is the version of hot reload I would previously have faked with a config file.
One line in the same spec page is worth pinning above your desk, because it is a product decision and not a technical one: for trust and safety, there should always be a human in the loop with the ability to deny tool invocations. Discovery makes tools easy to add. It does not make them safe to add.
Calling the tools
A call is tools/call with a name and an arguments object. My client does it like this, with a perf_counter either side:
async with stdio_client(PARAMS) as (r, w):
async with ClientSession(r, w) as s:
await s.initialize()
tools = await s.list_tools()
t0 = time.perf_counter()
res = await s.call_tool("lookup_company", {"domain": "n8n.io"})
t1 = time.perf_counter()
print(f"round-trip: {(t1 - t0) * 1000:.2f} ms")
And the output:
--- lookup_company('n8n.io') ---
{
"is_error": false,
"text": [
"{\n \"domain\": \"n8n.io\",\n \"found\": true,\n \"name\": \"n8n\",\n \"industry\": \"Workflow automation\",\n \"size\": \"51-200\",\n \"country\": \"DE\"\n}"
]
}
round-trip: 1.93 ms
--- log_lead(... stage='qualified') ---
{
"is_error": false
}
And the side effect landed where it should have:
$ cat leads.jsonl
{"email": "ops@n8n.io", "domain": "n8n.io", "stage": "qualified"}
One thing surprised me: structured_content came back as None on both calls even though my functions return dicts. The dict is serialised into a text content block instead. The spec allows structured results but ties them to an outputSchema on the tool definition, and I never declared one. If I wanted a client to validate my return shape as strictly as it validates my inputs, I would have to declare that schema. I did not, so I got text. That is a fair trade for a first version and a thing I would fix in a second one.
What a wrong argument looks like
This is where the difference stopped being theoretical for me. I deliberately called log_lead with a stage value that is not in the enum, and lookup_company with an integer where a string belongs.
--- log_lead(... stage='warm') # wrong value on purpose ---
{
"is_error": true,
"text": [
"Error executing tool log_lead: 1 validation error for log_leadArguments\nstage\n Input should be 'new', 'qualified' or 'dropped' [type=literal_error, input_value='warm', input_type=str]"
]
}
--- lookup_company(domain=42) # wrong type on purpose ---
{
"is_error": true,
"text": [
"Error executing tool lookup_company: 1 validation error for lookup_companyArguments\ndomain\n Input should be a valid string [type=string_type, input_value=42, input_type=int]"
]
}
My function body never ran in either case. The server also logged the rejections to stderr, so I could see them without instrumenting anything:
Tool 'log_lead' rejected arguments: ['stage']
Tool 'lookup_company' rejected arguments: ['domain']
Compare that to the webhook. If a caller posts {"stage": "warm"} to my n8n endpoint, the Code node runs, the Sheets node appends, and I now have a row with a stage value nothing downstream knows how to filter. The workflow succeeded. The data is wrong. That class of failure is the one I lose the most time to, and it is the one I have to write defensive nodes for by hand. Here I got it from a type annotation.
The spec draws a distinction I had not appreciated before reading it properly. Protocol errors, like calling a tool that does not exist, come back as JSON-RPC errors. Tool execution errors, which includes input validation, come back inside a normal result with isError: true. The reason is that the second kind is meant to be handed to the model so it can correct itself and retry. That is not error handling for my benefit. It is error handling for the agent's benefit, which is a different design goal from anything in my n8n error handling setup.
Timing both paths
I wanted a number, so I built the webhook side as a local Python http.server with the same lookup logic behind a POST endpoint, and hit it twenty times with urllib. Same machine, same process for the handler, same trivial dictionary read. Then twenty warm calls to the MCP tool over stdio.
| Path | First call | 20 warm calls | Per call |
|---|---|---|---|
| MCP tool over stdio | 1.93 ms | 12.67 ms | 0.63 ms |
| HTTP POST to loopback | not timed separately | 4.34 ms | 0.22 ms |
I want to be careful about what that table is and is not. It is a local measurement on one laptop, with handlers that do a dictionary lookup and nothing else, measured with time.perf_counter() around the call. It is not a benchmark of MCP against webhooks in production. What it measures is protocol overhead with everything else removed, and on that narrow question the HTTP path was about three times cheaper per call, at a difference of four tenths of a millisecond.
Four tenths of a millisecond is nothing next to a real network hop, a database read, or the several hundred milliseconds a model spends deciding to call the tool at all. I went in half expecting MCP to be the slow one and planning to say so. It is the slower one here, and it does not matter. If you are picking between these two on latency grounds, you are optimising the wrong stage. I made the same argument about voice pipelines in the voice agent latency benchmark: measure the stage that actually holds the time before you tune anything.
Side by side
| Property | n8n webhook | MCP server |
|---|---|---|
| Who decides a call happens | The external system that posts to the URL | The model, from the tool list and the conversation |
| How the caller learns the interface | Out of band: docs, a prompt, tribal knowledge | tools/list returns names, descriptions and schemas |
| Input validation | Whatever nodes you add by hand | JSON Schema from type annotations, before your code runs |
| Error shape | HTTP status plus whatever body you build | isError: true with text a model can act on |
| Adding a second operation | A second endpoint, a second path, a second integration | A second decorated function on the same connection |
| Auth | Basic, Header or JWT auth in the node, plus IP allowlist | Transport level. Nothing by default over stdio |
| Retries and run history | Built into the n8n execution log | Yours to build |
| Per call overhead (local, this test) | 0.22 ms | 0.63 ms |
The two rows I would read first are the last two. MCP gives you a better interface and hands back the operational furniture. n8n gives you the furniture and leaves the interface undescribed.
When a webhook is enough
Most of the time, in my own work. The test I apply is one question: does anything need to choose? If the answer is no, a webhook is the lighter tool and MCP is a second process and a second protocol for no return.
- An external event fires. A Stripe payment, a Shopify order, a Typeform submission, a Calendly booking. The event already knows what it is. There is no menu to read.
- One caller, one operation. If there is exactly one thing the endpoint does and exactly one system that calls it, discovery has nothing to discover.
- The schedule decides. Cron style triggers have no conversation and no choice. Nothing about MCP helps a nightly sync.
- You need the execution log more than the interface. n8n's run history has saved me more debugging hours than any typed schema would have. Do not trade it away casually.
- The consumer is code you control. If you wrote both ends, you already know the contract. Writing it down twice is not free.
None of my scheduled n8n workflows changed after this exercise, and I do not plan to change them. A model called from inside a workflow is still a workflow: my code decides when the model runs, and a webhook is the correct front door for it.
When MCP earns its keep
The pattern is the mirror image. If something has to pick, MCP is worth the extra process.
- The model picks the operation. The moment you have more than two or three tools and the right one depends on what the user said, hand-written prompt descriptions start drifting from the code. Discovery stops that by construction.
- The tool set changes while the agent lives. Adding a function to the server is the whole deployment. The
listChangednotification is in the spec for exactly this. - Wrong arguments are expensive. Anything that writes, charges, emails or schedules. I would rather a bad enum bounce at the schema than land in a ledger.
- More than one client uses the same capability. One server, several agents, one definition of what the operation accepts. I have maintained the alternative, which is three prompts describing the same endpoint slightly differently, and I would not go back.
- You want the model to recover from its own mistakes. The
isErrorconvention exists so the model can read the validation message and retry. That loop only works if the error text is written for a reader, which is a reason to write real tool descriptions.
For work where an agent is qualifying or routing in real time, this is the difference between a demo and something you can leave running. I made a related point about qualification flows in how AI agents qualify leads faster, and about the organisational side in agentic AI in the enterprise. If the deciding you want is happening on a phone call rather than in a chat window, the product side of that lives on Voxdonna's voice agents page.
What I would do differently
Four things, in the order I would fix them.
Declare an output schema. My structured content came back empty because I typed the return as dict and stopped there. The inputs got schema enforcement for free and the outputs got nothing. Next time I define a return model so the client can validate both directions.
Wrap the existing webhook instead of reimplementing it. I rewrote the lookup in Python because it was three lines. For anything with real workflow logic, credentials or retry behaviour, the tool body should be one HTTP POST to the n8n production URL. You keep the execution history and the credential store and still get a typed, discoverable front door. That is the migration I would actually ship, and it is far less work than it sounds.
Write the docstrings for the model, not for me. "Look up a company by its web domain" is fine. "Use this before log_lead when you only have an email address" would be better, because it tells the model when to call the tool and not just what it does. The description field is prompt engineering wearing a docstring costume, and I under-invested in it.
Decide the auth story before the second tool. Over stdio the server inherits the trust of the process that launched it, which is fine for a local experiment and not fine for anything shared. The n8n side had Basic, Header and JWT auth sitting in a dropdown. On the MCP side that is a transport decision I have to make on purpose. I would rather make it before the server has a tool that writes to something real.
The rule I use now
If the caller already knows which operation it wants, use a webhook. If the caller has to choose, use an MCP server. If the caller is a model and the operation writes to something you care about, use an MCP server and declare the schemas on both sides.
That is it. It is not about speed, because the speed difference here was four tenths of a millisecond and it went the wrong way for the newer protocol. It is about whether discovery and typing are doing work for you, or whether you are paying for a menu that nobody reads.
The version of this I ship next keeps every n8n workflow exactly where it is and puts a thin MCP server in front of three of them. If you want to sanity check whether that is worth your time before you build it, the ROI calculator will give you the hours side, and the AI readiness assessment covers whether the rest of your stack is ready for an agent that can act. The wider picture of how these pieces fit together is on the AI agents hub.
Frequently Asked Questions
Does an MCP server replace my n8n webhooks?
No. They answer different questions. A webhook is the right shape when something outside your system decides that work should happen and hands you a payload. An MCP server is the right shape when a model decides that work should happen and needs to pick the right operation from a list. In my own setup both exist side by side: the webhook keeps catching form posts, and the MCP server wraps the same two functions so an agent can call them by name.
Is an MCP server slower than a webhook?
On my laptop it was, slightly. Twenty warm calls over stdio to a local MCP server averaged 0.63 ms each. Twenty warm HTTP POSTs to a local Python http.server averaged 0.22 ms each. Both numbers are loopback measurements on one machine with trivial handlers, so they measure protocol overhead and nothing else. Against a real network hop or a real database read, that difference disappears into the noise.
What does MCP give me that a webhook does not?
Three things I could point at in the output. Discovery: the client asks tools/list and gets back every tool with a name, a description and a JSON Schema, without me writing that description anywhere in a prompt. Typed arguments: the schema is enforced before my function body runs, so a bad value comes back as a validation error rather than a silent wrong answer. Selection: the model chooses which tool to call and when, instead of my code choosing for it.
When is MCP overkill?
Whenever no model is in the loop. A Stripe event firing into a workflow, a cron job, a form submission, a Shopify order: none of those involve anything choosing between options, so the discovery and schema machinery buys you nothing and you pay for a second process and a second protocol. If the caller already knows exactly which operation it wants, a webhook or a plain HTTP endpoint is the lighter tool.
Can an MCP server call my existing n8n workflow?
Yes, and that is the cheapest migration path. The tool function body can be a single HTTP POST to the n8n production webhook URL you already run. You keep the workflow, the credentials and the execution history in n8n, and the MCP server becomes a typed, discoverable front door for a model. I would start there before rewriting any workflow logic in Python.
Which Python package should I use to build an MCP server?
I used the official SDK, installed with pip install mcp, which resolved to version 2.2.0 on Python 3.14.6. Version 2 renamed the FastMCP class to MCPServer and switched protocol model fields from camelCase to snake_case, so most tutorials written against version 1 will not run as printed. If you need the old API, the SDK error message itself suggests pinning mcp<2.
Want a second pair of eyes on your build?
Book a free 30-minute discovery call. Bring the workflow you are unsure about and we will work out whether it wants a webhook, a tool, or neither.