You've built a workflow in n8n that queries your CRM, enriches a contact, and updates a deal stage. It works. Now your team wants Claude Desktop, Cursor, or an internal AI agent to call that workflow as a tool. So you slap a Webhook trigger on it, point an MCP client at the URL, and move on.
Three weeks later, an AI agent in a misconfigured loop calls your tool 4,000 times in an hour. Or someone discovers the endpoint has no auth and starts probing it. Or the tool accepts arbitrary parameters that your workflow never validated, and a crafted payload overwrites production data.
Exposing an n8n workflow as an MCP (Model Context Protocol) tool isn't just "make a webhook respond to JSON-RPC." The MCP spec has specific handshake requirements, transport expectations, and security considerations that most developers skip the first time. This article walks through doing it properly—authentication, input validation, network isolation, scoping, rate limiting, and audit trails—without turning your automation platform into an attack surface.
TL;DR: Use n8n's MCP Server Trigger (or a Webhook trigger implementing the MCP JSON-RPC contract) behind a reverse proxy with TLS. Authenticate every request with at minimum a bearer token; prefer OAuth 2.1 for multi-client setups. Validate all tool inputs against the JSON Schema you declare. Scope the underlying workflow to read-only operations where possible. Rate-limit at the proxy layer. Log every tools/call invocation with the caller identity.
📋 Table of Contents
- 1. What MCP Actually Expects From Your Workflow
- 2. Two Paths: Native MCP Server Trigger vs. Manual Webhook
- 3. Locking Down the Transport: TLS, Reverse Proxy, Network Isolation
- 4. Authentication That Survives Production
- 5. Input Validation: Your Schema Is the Firewall
- 6. Least Privilege for the Underlying Workflow
- 7. Rate Limiting Before Your Tool Becomes a DDoS Vector
- 8. Audit Logging: You Will Need This at 3 AM
- Production Security Checklist
1. What MCP Actually Expects From Your Workflow
Before you configure a single n8n node, you need to understand the protocol contract. MCP isn't a generic "call my API" standard. It's a structured JSON-RPC 2.0 protocol with a specific lifecycle.
When an MCP client (Claude Desktop, Cursor, VS Code with Copilot, a custom agent) connects to your server, it sends an initialize request. Your server responds with its capabilities. The client then calls tools/list to discover available tools. When the AI decides to use a tool, it sends tools/call with the tool name and arguments.
The three methods your n8n workflow must handle:
// 1. initialize
{ "jsonrpc": "2.0", "id": 1, "method": "initialize", "params": { "protocolVersion": "2025-03-26", "capabilities": {}, "clientInfo": { "name": "claude-desktop", "version": "1.0.12" } } }
// 2. tools/list
{ "jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {} }
// 3. tools/call
{ "jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": { "name": "lookup_contact", "arguments": { "email": "jane@acme.com" } } }
Your responses must follow JSON-RPC 2.0 structure. For tools/list, you return an array of tool definitions, each with a name, description, and inputSchema (a JSON Schema object describing accepted parameters). For tools/call, you return a content array with text or structured results.
The current MCP spec (as of the 2025-03-26 protocol version) also supports tool annotations—metadata hints that tell clients about the tool's behavior:
{
"name": "update_deal_stage",
"description": "Changes the stage of a deal in the CRM",
"inputSchema": { ... },
"annotations": {
"readOnlyHint": false,
"destructiveHint": false,
"idempotentHint": true,
"openWorldHint": false
}
}
These annotations matter for security. A client that respects destructiveHint: true can add confirmation steps before calling your tool. A client that checks readOnlyHint might skip authentication for read-only tools or cache their results. Declare them honestly.
🔍 Why this matters: If your n8n workflow doesn't properly handle the
initializehandshake or returns malformedtools/listresponses, MCP clients will silently fail to discover your tools. You'll spend an hour debugging "why Claude can't see my tool" before realizing the JSON-RPC envelope was wrong.
2. Two Paths: Native MCP Server Trigger vs. Manual Webhook
n8n gives you two practical ways to expose a workflow as an MCP tool. The choice depends on your n8n version and how much protocol control you need.
Path A: MCP Server Trigger node (n8n 1.78+)
Recent n8n versions include an MCP Server Trigger node that handles the JSON-RPC protocol for you. You configure the trigger with tool definitions—name, description, input schema—and n8n manages the initialize, tools/list, and tools/call routing internally.
The workflow structure looks like:
MCP Server Trigger → [Your Logic Nodes] → Respond to MCP Client
In the trigger configuration, you define each tool:
- Tool Name:
lookup_contact - Description: "Finds a contact in the CRM by email address"
- Input Schema: JSON Schema defining accepted parameters
When the trigger receives a tools/call for lookup_contact, it passes the parsed arguments into the workflow as input data. Your downstream nodes (CRM lookup, transformation, etc.) execute normally, and the final node's output becomes the MCP response content.
This is the fastest path to a working MCP tool. The protocol plumbing is handled. You focus on the workflow logic.
Path B: Webhook trigger with manual protocol handling
If you're on an older n8n version, need custom protocol behavior, or want finer control over error responses, you can implement the MCP contract manually using a standard Webhook trigger.
Webhook (POST) → Switch (route by method) →
├─ "initialize" → Respond with capabilities
├─ "tools/list" → Respond with tool definitions
└─ "tools/call" → [Your Logic] → Respond with result
The Webhook trigger is configured for POST with JSON body parsing. A Switch node routes based on {{ $json.body.method }}. Each branch builds the appropriate JSON-RPC response.
Here's the tools/list response you'd construct in a Code node:
const tools = [{
name: "lookup_contact",
description: "Finds a contact in the CRM by email address and returns their details",
inputSchema: {
type: "object",
properties: {
email: {
type: "string",
format: "email",
description: "The email address of the contact to look up"
}
},
required: ["email"],
additionalProperties: false
},
annotations: {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false
}
}];
return [{
json: {
jsonrpc: "2.0",
id: $json.body.id,
result: { tools }
}
}];
Notice additionalProperties: false in the schema. This is your first line of defense—clients that respect the schema won't send unexpected parameters. But as we'll see in section 5, you still validate server-side.
⚠️ Gotcha: With the manual Webhook approach, n8n's Webhook node returns a 200 by default. MCP over HTTP expects specific status codes. A 200 with a JSON-RPC error object is correct for protocol-level errors. But for transport-level issues (bad auth, malformed JSON), you need to return 401 or 400. Use n8n's Respond to Webhook node with explicit status codes, or handle this at the reverse proxy layer.
3. Locking Down the Transport: TLS, Reverse Proxy, Network Isolation
Your n8n instance should never be directly accessible from the internet. This isn't specific to MCP—it's basic production hygiene—but MCP makes it more urgent because AI clients will call your endpoint autonomously, potentially at high frequency, without human oversight.
The architecture that works:
MCP Client (Claude Desktop, Cursor, etc.)
│
▼ HTTPS (TLS 1.2+)
┌─────────────────────┐
│ Reverse Proxy │ ← TLS termination, rate limiting, auth
│ (nginx / Caddy / │
│ Traefik / Cloudflare Tunnel)
└─────────┬───────────┘
│ HTTP (internal)
▼
┌─────────────────────┐
│ n8n instance │ ← Listens on localhost or private network
│ (port 5678) │
└─────────────────────┘
For n8n specifically, you want:
# n8n environment configuration
N8N_HOST=n8n.internal.yourcompany.com
N8N_PROTOCOL=http # TLS terminated at proxy
WEBHOOK_URL=https://mcp.yourcompany.com/
N8N_SSL_KEY= # Empty - proxy handles TLS
N8N_SSL_CERT=
Set WEBHOOK_URL to your public HTTPS endpoint. n8n uses this to generate webhook URLs, and it must be the externally reachable address.
If you're using Docker:
# docker-compose.yml (simplified)
services:
n8n:
image: n8nio/n8n:latest
ports:
- "127.0.0.1:5678:5678" # Bind to localhost only
environment:
- WEBHOOK_URL=https://mcp.yourcompany.com/
# ... other config
caddy:
image: caddy:2
ports:
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
depends_on:
- n8n
The critical line is "127.0.0.1:5678:5678"—n8n only listens on localhost. The reverse proxy is the only entry point.
For Caddy, automatic HTTPS makes this trivial:
mcp.yourcompany.com {
reverse_proxy n8n:5678
# MCP-specific: allow long-lived connections for SSE if using streamable HTTP
@mcp path /mcp/*
handle @mcp {
reverse_proxy n8n:5678 {
flush_interval -1 # Keep connection open for streaming
}
}
}
💡 Practical note: If your MCP clients are desktop applications (Claude Desktop, Cursor), they need to reach your endpoint over the public internet. If your n8n instance is on a private network with no public IP, look at Cloudflare Tunnel or Tailscale Funnel. Don't port-forward n8n directly.
4. Authentication That Survives Production
MCP over HTTP supports authentication via standard HTTP headers. The spec recommends OAuth 2.1 for complex multi-client scenarios, but for most n8n use cases, bearer tokens are simpler and sufficient.
Simple: Static bearer token
Generate a strong token and require it on every request:
openssl rand -hex 32
# a1b2c3d4e5f6...
In your n8n workflow, add validation as the first node after the trigger. If you're using the Webhook trigger, add a Code node that checks the Authorization header:
const authHeader = $json.headers['authorization'] || '';
const expectedToken = 'a1b2c3d4e5f6...'; // Store in n8n credentials, not hardcoded
if (!authHeader.startsWith('Bearer ')) {
return [{ json: { error: true, statusCode: 401, message: 'Missing bearer token' } }];
}
const token = authHeader.slice(7);
if (token !== expectedToken) {
return [{ json: { error: true, statusCode: 401, message: 'Invalid token' } }];
}
// Token valid - continue to protocol routing
return [{ json: $json }];
But don't hardcode the token. Store it in n8n's credential system and reference it via environment variable or credential expression.
Better: Per-client tokens with the reverse proxy
If multiple MCP clients need access, issue separate tokens and validate at the proxy layer:
# nginx config
location /mcp/ {
# Validate token before reaching n8n
set $valid_token "off";
if ($http_authorization = "Bearer client-claude-desktop-token-xyz") {
set $valid_token "on";
set $client_id "claude-desktop";
}
if ($http_authorization = "Bearer client-cursor-token-abc") {
set $valid_token "on";
set $client_id "cursor";
}
if ($valid_token = "off") {
return 401 '{"jsonrpc":"2.0","error":{"code":-32000,"message":"Unauthorized"}}';
}
proxy_pass http://n8n:5678;
proxy_set_header X-Client-ID $client_id;
proxy_set_header X-Forwarded-For $remote_addr;
}
This keeps auth logic out of n8n entirely. The workflow receives a pre-validated request with an X-Client-ID header it can use for logging and scoping.
For teams: OAuth 2.1
The MCP spec's recommended auth for HTTP servers is OAuth 2.1 with PKCE. This makes sense when you have multiple users with different permission levels, or when integrating with an existing identity provider. For a single-team n8n instance with 2–3 MCP clients, it's overkill. For an organization exposing n8n tools to dozens of AI agents across departments, it's worth the setup.
🚨 Production warning: Never pass MCP tool authentication tokens in URL query parameters. They end up in access logs, browser history (if ever opened in a browser), and proxy logs. Always use the
Authorizationheader.
5. Input Validation: Your Schema Is the Firewall
The inputSchema you declare in tools/list is a contract with the client. But a malicious or misconfigured client can send anything. Your n8n workflow must validate inputs independently of what the schema promises.
This is especially critical for AI agent callers because LLMs sometimes hallucinate parameter values, invent extra fields, or send the wrong type. You're not just defending against attackers—you're defending against confused AI.
Add a validation node immediately after the trigger, before any business logic:
// Validation Code node - runs on every tools/call
const { name, arguments: args } = $json.body.params;
// Tool-specific validation
const schemas = {
lookup_contact: (args) => {
if (!args.email || typeof args.email !== 'string') {
return { valid: false, error: 'email is required and must be a string' };
}
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(args.email)) {
return { valid: false, error: 'email format is invalid' };
}
// Reject unexpected properties
const allowed = ['email'];
const unexpected = Object.keys(args).filter(k => !allowed.includes(k));
if (unexpected.length > 0) {
return { valid: false, error: `Unexpected parameters: ${unexpected.join(', ')}` };
}
return { valid: true };
},
update_deal_stage: (args) => {
if (!args.dealId || typeof args.dealId !== 'string') {
return { valid: false, error: 'dealId is required' };
}
const validStages = ['prospecting', 'qualification', 'proposal', 'closed_won', 'closed_lost'];
if (!validStages.includes(args.newStage)) {
return { valid: false, error: `newStage must be one of: ${validStages.join(', ')}` };
}
return { valid: true };
}
};
const validate = schemas[name];
if (!validate) {
return [{ json: { error: true, message: `Unknown tool: ${name}` } }];
}
const result = validate(args || {});
if (!result.valid) {
return [{ json: {
jsonrpc: "2.0",
id: $json.body.id,
error: { code: -32602, message: result.error }
}}];
}
return [{ json: $json }]; // Valid - continue to business logic
The key patterns here:
- Type checking: Don't assume the LLM sends the right type. Check
typeof. - Allowlist validation: Reject any parameter not in your schema.
additionalProperties: falsein the JSON Schema tells well-behaved clients, but your code enforces it. - Enum enforcement: For fields with valid ranges (stages, statuses, categories), validate explicitly.
- Format validation: Emails, UUIDs, dates—validate format, not just presence.
Return JSON-RPC error code -32602 (Invalid params) for validation failures. This tells the MCP client the error is in the arguments, which some clients use to decide whether to retry with corrected parameters.
6. Least Privilege for the Underlying Workflow
Your MCP tool's n8n workflow likely connects to external services—CRM APIs, databases, internal tools. The credentials and permissions those connections use should be scoped to exactly what the tool needs.
Scenario: Your lookup_contact tool queries HubSpot. The workflow uses your team's HubSpot API key with full CRM access. A crafted MCP request somehow triggers a code path that writes data. Now an AI agent can modify your CRM through a tool that was supposed to be read-only.
The fix: Create dedicated credentials for MCP-exposed workflows with minimal permissions.
| Tool Purpose | Credential Scope | What to Avoid |
|---|---|---|
| Read contact data | API key with crm.contacts.read only | Full CRM admin key |
| Update deal stage | API key with crm.deals.write on specific pipeline | Global write access |
| Send Slack notification | Bot token for one specific channel | Workspace admin token |
| Query database | Read-only DB user with row-level security | Superuser connection |
In n8n, this means creating separate credential entries. Don't reuse your admin credentials for MCP-exposed workflows. Create a credential named something explicit like hubspot-mcp-readonly so anyone looking at the workflow understands the scope.
For database tools, use a dedicated database user:
CREATE USER mcp_readonly WITH PASSWORD 'strong-password-here';
GRANT CONNECT ON DATABASE crm TO mcp_readonly;
GRANT USAGE ON SCHEMA public TO mcp_readonly;
GRANT SELECT ON contacts, deals TO mcp_readonly;
-- Explicitly no INSERT, UPDATE, DELETE
If your tool needs to write, create a second user with narrow write permissions:
CREATE USER mcp_deal_writer WITH PASSWORD 'another-strong-password';
GRANT CONNECT ON DATABASE crm TO mcp_deal_writer;
GRANT USAGE ON SCHEMA public TO mcp_deal_writer;
GRANT UPDATE (stage) ON deals TO mcp_deal_writer;
-- Can only update the stage column, not deal amount, owner, etc.
This is defense in depth. Even if your input validation has a gap, even if the AI agent sends unexpected parameters that slip through, the database won't allow operations beyond the tool's intended scope.
7. Rate Limiting Before Your Tool Becomes a DDoS Vector
AI agents don't behave like humans. A human might click a button 10 times in frustration. An AI agent in a retry loop can call your tool 500 times in 30 seconds. Without rate limiting, a single misconfigured client can overwhelm your n8n instance or, worse, overwhelm the downstream services your workflow calls.
Implement rate limiting at the reverse proxy, not in n8n. n8n's single-threaded execution model (even in queue mode) isn't designed for high-frequency request filtering.
nginx with limit_req:
# Define rate limit zones
limit_req_zone $http_authorization zone=mcp_per_client:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=mcp_per_ip:10m rate=30r/s;
location /mcp/ {
# Per-client rate limit (based on auth token)
limit_req zone=mcp_per_client burst=20 nodelay;
# Per-IP as fallback
limit_req zone=mcp_per_ip burst=50 nodelay;
limit_req_status 429;
proxy_pass http://n8n:5678;
}
Caddy with rate limiting (via plugin or external middleware):
Caddy doesn't have built-in rate limiting in the standard distribution, but you can use the caddy-ratelimit plugin or handle it with a lightweight middleware service. For simpler setups, Cloudflare's rate limiting rules work well if your traffic routes through their proxy.
For n8n queue mode: If you're running n8n in queue mode (Redis + workers), you have natural backpressure—the queue will fill up and workers process at their own pace. But this doesn't protect your downstream APIs. A queue with 10,000 pending executions all hitting HubSpot's API simultaneously will get you rate-limited or banned at the provider level.
Add a throttle in your workflow for external API calls:
// Before calling external API - simple token bucket
const now = Date.now();
const windowMs = 60000; // 1 minute
const maxCalls = 30;
// Use n8n's static data for simple in-memory rate tracking
const staticData = this.getWorkflowStaticData('global');
if (!staticData.rateWindow) {
staticData.rateWindow = { start: now, count: 0 };
}
if (now - staticData.rateWindow.start > windowMs) {
staticData.rateWindow = { start: now, count: 0 };
}
staticData.rateWindow.count++;
if (staticData.rateWindow.count > maxCalls) {
const waitTime = windowMs - (now - staticData.rateWindow.start);
return [{ json: {
jsonrpc: "2.0",
id: $json.body.id,
error: { code: -32000, message: `Rate limit exceeded. Retry after ${Math.ceil(waitTime/1000)}s` }
}}];
}
return [{ json: $json }]; // Continue to API call
⚠️ Gotcha: n8n's static data is per-worker in queue mode. If you run multiple workers, each has its own rate counter. For accurate distributed rate limiting, use Redis directly in your workflow or enforce limits at the proxy layer only.
8. Audit Logging: You Will Need This at 3 AM
When an AI agent does something unexpected with your MCP tool—and it will—you need a record of what happened, when, who called it, and what parameters were passed. n8n's built-in execution history helps, but it's not structured for security auditing.
Log every tools/call invocation to a dedicated store. At minimum, capture:
- Timestamp
- Client identity (from auth token or
X-Client-IDheader) - Tool name
- Input arguments (sanitized—no secrets)
- Response status (success/error)
- Execution time
- n8n execution ID (for debugging)
A Postgres table works well:
CREATE TABLE mcp_audit_log (
id SERIAL PRIMARY KEY,
timestamp TIMESTAMPTZ DEFAULT NOW(),
client_id VARCHAR(255),
tool_name VARCHAR(255) NOT NULL,
input_args JSONB,
success BOOLEAN,
error_message TEXT,
execution_time_ms INTEGER,
n8n_execution_id VARCHAR(255),
source_ip INET
);
CREATE INDEX idx_mcp_audit_time ON mcp_audit_log(timestamp);
CREATE INDEX idx_mcp_audit_tool ON mcp_audit_log(tool_name);
CREATE INDEX idx_mcp_audit_client ON mcp_audit_log(client_id);
In your n8n workflow, add a logging node after the tool logic completes (and in the error path):
// Audit logging node - runs after tool execution
const startTime = $json._executionStartTime || Date.now();
const duration = Date.now() - startTime;
const logEntry = {
client_id: $json.headers['x-client-id'] || 'unknown',
tool_name: $json.body.params?.name || 'unknown',
input_args: this.sanitizeArgs($json.body.params?.arguments || {}),
success: !$json.error,
error_message: $json.error?.message || null,
execution_time_ms: duration,
n8n_execution_id: $json.execution?.id,
source_ip: $json.headers['x-forwarded-for']?.split(',')[0]?.trim()
};
// Write to Postgres via n8n's database node
return [{ json: logEntry }];
The sanitizeArgs helper removes any field that looks like a credential:
sanitizeArgs(args) {
const sanitized = { ...args };
const sensitivePatterns = [/password/i, /token/i, /secret/i, /key/i, /authorization/i];
for (const key of Object.keys(sanitized)) {
if (sensitivePatterns.some(p => p.test(key))) {
sanitized[key] = '[REDACTED]';
}
}
return sanitized;
}
This audit trail serves three purposes: security incident investigation, debugging AI agent behavior ("why did Claude call update_deal_stage with newStage: 'banana'?"), and usage analytics for understanding which tools your AI agents actually rely on.
Production Security Checklist
Before you point a production MCP client at your n8n workflow, walk through this:
Transport
- TLS terminated at reverse proxy (not n8n directly)
- n8n bound to localhost or private network interface
-
WEBHOOK_URLset to the public HTTPS endpoint - No direct port exposure to the internet
Authentication
- Bearer token or OAuth 2.1 required on every request
- Tokens stored in n8n credentials or environment variables, not hardcoded
- Per-client tokens if multiple MCP clients access the tool
- Auth validated before any workflow logic executes
Input Validation
- All tool parameters validated against declared schema
-
additionalProperties: falseenforced server-side - Type checking on every field
- Enum/range validation where applicable
- JSON-RPC error code
-32602returned for invalid inputs
Scoping
- External service credentials are read-only where possible
- Database users have minimal grants
- Tool annotations (
readOnlyHint,destructiveHint) accurately declared - No admin/superuser credentials used in MCP-exposed workflows
Rate Limiting
- Per-client rate limit at proxy layer
- Burst allowance for legitimate spikes
- Downstream API rate limits respected in workflow logic
- 429 responses include retry-after guidance
Observability
- Every
tools/calllogged with client ID, tool name, args, outcome - Sensitive fields redacted from audit logs
- n8n execution history retention configured (not pruned too aggressively)
- Alerting on unusual patterns (high error rate, new client IDs, volume spikes)
The last item deserves emphasis. The first time an AI agent calls your MCP tool at 2 AM with parameters you never expected, you want to be looking at a structured audit log, not trying to reconstruct what happened from n8n's execution history while half-asleep. Build the logging before you need it.