Stop Building AI Workflows Twice: Share the Same MCP Tools Between n8n and Laravel

You build an AI workflow in n8n to enrich inbound leads. It calls your CRM, checks the customer’s plan, creates a support ticket if needed, and posts a summary to Slack.

Then the product team asks for the same capability inside the Laravel app: an assistant that can search customers, check billing status, and create follow-up tasks.

So you build it again.

Different runtime. Different error handling. Different auth. Different logs. Different rate limits. Eventually, the n8n workflow creates tickets with one set of rules, while Laravel creates them with another. Now the automation and the product disagree about what “create a support ticket” means.

This is the hidden cost of AI integration: capabilities get duplicated across runtimes.

The fix is not “use the same prompt.” The fix is to make the capability itself shared.

That is where MCP, the Model Context Protocol, becomes useful. Instead of treating n8n and Laravel as separate AI universes, you expose a common set of MCP tools: crm.contacts.search, billing.subscriptions.get, support.tickets.create.preview, and so on. Laravel owns the domain rules. n8n calls the same tools as any other client. Your in-app assistant uses them too.

TL;DR: Put your MCP tool definitions and execution logic in one place, preferably close to your Laravel domain layer. Expose them through a controlled MCP-compatible endpoint. Let n8n call those tools via its MCP client or a plain HTTP JSON-RPC adapter. Use one authorization model, one validation layer, one audit log, and one idempotency strategy.

📋 Table of Contents


The real problem: duplicated AI capabilities

Most teams do not start with a bad architecture. They start with a demo.

The n8n workflow needs to call a few HTTP endpoints. The Laravel assistant needs to do the same thing, so it calls those endpoints too. Then the logic grows:

  • add validation,
  • add retries,
  • add tenant filtering,
  • add permission checks,
  • add audit logs,
  • add rate limits,
  • add human approval for destructive actions.

Soon the same “tool” exists in three places:

n8n workflow logic
Laravel assistant logic
Legacy REST endpoint logic

The tools drift.

The n8n workflow retries and creates duplicate records. The Laravel assistant validates one field but not another. The support team sees different behavior depending on whether a request came from the product UI or the automation.

MCP helps because it gives you a standard contract for capabilities:

  • tool name,
  • description,
  • input schema,
  • structured result,
  • structured error.

But MCP does not magically solve duplication. It gives you a better place to centralize the contract. The architectural decision is this:

n8n and Laravel should not both implement the tool. They should both consume the same tool.


1. Think in capabilities, not workflow steps

Scenario:
Your n8n workflow has an HTTP Request node called “Get customer,” then a Function node that renames fields, then another node that checks subscription status. Your Laravel assistant has a service class that does the same thing, but with slightly different field names.

Why it matters:
Workflow steps are implementation details. Capabilities are stable contracts. AI agents and automation workflows should consume capabilities, not reconstruct business logic from raw HTTP calls.

Solution:
Name tools around user-intentful actions.

Avoid low-level names:

http.get
api.request
database.query
models.update

Prefer domain capabilities:

crm.contacts.search
crm.contacts.get
billing.subscriptions.get
support.tickets.search
support.tickets.create.preview
support.tickets.create.commit
notifications.send.summary

A good MCP tool definition answers:

  • What does this do?
  • What inputs does it require?
  • What inputs are forbidden?
  • Is it read-only or mutating?
  • What errors can happen?
  • What does the caller need to know?

Example:

{
  "name": "crm.contacts.search",
  "description": "Search CRM contacts by email, domain, or name. Returns a compact summary only.",
  "inputSchema": {
    "type": "object",
    "properties": {
      "email": {
        "type": "string",
        "format": "email"
      },
      "domain": {
        "type": "string"
      },
      "limit": {
        "type": "integer",
        "minimum": 1,
        "maximum": 25
      }
    },
    "additionalProperties": false
  }
}

This is not just documentation. It is the contract that both n8n and Laravel will rely on.

A useful rule: if the same capability is needed by a workflow and by an in-app assistant, it deserves a tool. If it is only needed by one workflow, it may be fine as a normal workflow step.

🔍 Why this matters:
The goal is not “use MCP everywhere.” The goal is to stop encoding business rules inside every consumer.


2. Put the MCP tool server next to your domain rules

Now the big architectural question: where should the shared MCP tools live?

You have a few options:

  1. Inside the Laravel application.
  2. In a standalone MCP gateway service.
  3. Inside n8n, exposing workflows as tools.
  4. In a separate AI middleware layer.

For most Laravel products, the best default is to put the MCP tool server next to the domain rules.

That usually means Laravel.

Why?

Because Laravel is usually where you already have:

  • Eloquent models,
  • policies,
  • validation,
  • events,
  • queues,
  • audit logs,
  • rate limiting,
  • tenant scopes,
  • service classes,
  • API resources,
  • and transactional boundaries.

If your MCP server lives somewhere else, it will eventually need to call Laravel over HTTP to enforce those rules. At that point, you have added a network hop without removing the dependency.

A good split looks like this:

n8n
  ↓ MCP tool call
Laravel MCP endpoint
  ↓
Tool dispatcher
  ↓
Domain service / action / policy
  ↓
Database / external API / queue

The MCP layer should be thin. It translates MCP requests into validated domain calls.

It should not contain business rules like:

If the customer is on enterprise, allow refunds over $1,000.

That belongs in your Laravel domain layer.

It should contain:

Tool name
Input schema
Argument validation
Authorization check
Result shaping
Error shaping

This is the same discipline you would apply to a controller.

💡 Practical note:
If your n8n workflows are the only source of truth for a process, then maybe n8n should own the tool. But if Laravel owns the domain model, Laravel is usually the right home for shared MCP tools.


3. Build a Laravel MCP endpoint that both apps and automations can call

Let’s make this concrete.

You can expose a minimal MCP-compatible endpoint from Laravel. Some MCP clients expect more advanced transports, such as Server-Sent Events or streamable HTTP. For backend-to-backend integrations, a JSON-over-HTTP endpoint is often enough. If your MCP client requires a specific transport, you can adapt the transport while keeping the same tool dispatcher underneath.

Route:

use App\Http\Controllers\McpController;
use Illuminate\Support\Facades\Route;

Route::post('/mcp', McpController::class)
    ->middleware(['auth:sanctum', 'throttle:mcp']);

Controller:

<?php

namespace App\Http\Controllers;

use App\Mcp\ToolDispatcher;
use App\Mcp\ToolRegistry;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;

class McpController extends Controller
{
    public function __invoke(
        Request $request,
        ToolRegistry $registry,
        ToolDispatcher $dispatcher
    ): JsonResponse|Response {
        $method = $request->input('method');
        $id = $request->input('id');
        $params = $request->input('params', []);

        return match ($method) {
            'initialize' => $this->result($id, [
                'protocolVersion' => $params['protocolVersion'] ?? '1.0',
                'capabilities' => [
                    'tools' => new \stdClass(),
                ],
                'serverInfo' => [
                    'name' => config('app.name'),
                    'version' => config('app.version', '1.0.0'),
                ],
            ]),

            'notifications/initialized' => response()->noContent(),

            'tools/list' => $this->result($id, [
                'tools' => $registry->forContext($request->toolContext()),
            ]),

            'tools/call' => $this->result($id, $dispatcher->call(
                context: $request->toolContext(),
                name: $params['name'] ?? '',
                arguments: $params['arguments'] ?? []
            )),

            default => $this->error($id, -32601, "Method {$method} not found"),
        };
    }

    private function result(mixed $id, array $result): JsonResponse
    {
        return response()->json([
            'jsonrpc' => '2.0',
            'id' => $id,
            'result' => $result,
        ]);
    }

    private function error(mixed $id, int $code, string $message): JsonResponse
    {
        return response()->json([
            'jsonrpc' => '2.0',
            'id' => $id,
            'error' => [
                'code' => $code,
                'message' => $message,
            ],
        ]);
    }
}

The important part is not the exact transport. The important part is that tools/list and tools/call go through the same registry and dispatcher that Laravel uses internally.

A tool registry can filter tools by client or user:

<?php

namespace App\Mcp;

class ToolRegistry
{
    public function forContext(ToolContext $context): array
    {
        return collect($this->tools())
            ->filter(fn (array $tool) => $context->canUseTool($tool['name']))
            ->values()
            ->all();
    }

    private function tools(): array
    {
        return [
            [
                'name' => 'crm.contacts.search',
                'description' => 'Search CRM contacts by email, domain, or name.',
                'inputSchema' => [
                    'type' => 'object',
                    'properties' => [
                        'email' => ['type' => 'string', 'format' => 'email'],
                        'domain' => ['type' => 'string'],
                        'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 25],
                    ],
                    'additionalProperties' => false,
                ],
            ],
            [
                'name' => 'billing.subscriptions.get',
                'description' => 'Get the active subscription for a customer.',
                'inputSchema' => [
                    'type' => 'object',
                    'properties' => [
                        'customer_id' => ['type' => 'integer'],
                    ],
                    'required' => ['customer_id'],
                    'additionalProperties' => false,
                ],
            ],
            [
                'name' => 'support.tickets.create.preview',
                'description' => 'Preview support ticket creation without submitting it.',
                'inputSchema' => [
                    'type' => 'object',
                    'properties' => [
                        'customer_id' => ['type' => 'integer'],
                        'subject' => ['type' => 'string', 'maxLength' => 200],
                        'body' => ['type' => 'string', 'maxLength' => 5000],
                        'priority' => [
                            'type' => 'string',
                            'enum' => ['low', 'normal', 'high'],
                        ],
                    ],
                    'required' => ['customer_id', 'subject', 'body'],
                    'additionalProperties' => false,
                ],
            ],
            [
                'name' => 'support.tickets.create.commit',
                'description' => 'Create a support ticket using a valid preview confirmation token.',
                'inputSchema' => [
                    'type' => 'object',
                    'properties' => [
                        'confirmation_token' => ['type' => 'string'],
                        'idempotency_key' => ['type' => 'string', 'maxLength' => 64],
                    ],
                    'required' => ['confirmation_token', 'idempotency_key'],
                    'additionalProperties' => false,
                ],
            ],
        ];
    }
}

The dispatcher executes the tool:

<?php

namespace App\Mcp;

use App\Mcp\Tools\Billing\GetSubscriptionTool;
use App\Mcp\Tools\Crm\SearchContactsTool;
use App\Mcp\Tools\Support\CommitTicketTool;
use App\Mcp\Tools\Support\PreviewTicketTool;
use Illuminate\Validation\ValidationException;
use Throwable;

class ToolDispatcher
{
    public function __construct(
        private SearchContactsTool $searchContacts,
        private GetSubscriptionTool $getSubscription,
        private PreviewTicketTool $previewTicket,
        private CommitTicketTool $commitTicket
    ) {
    }

    public function call(ToolContext $context, string $name, array $arguments): array
    {
        try {
            $payload = match ($name) {
                'crm.contacts.search' => $this->searchContacts->handle($context, $arguments),
                'billing.subscriptions.get' => $this->getSubscription->handle($context, $arguments),
                'support.tickets.create.preview' => $this->previewTicket->handle($context, $arguments),
                'support.tickets.create.commit' => $this->commitTicket->handle($context, $arguments),
                default => [
                    'error' => "Unknown tool: {$name}",
                ],
            };

            return [
                'content' => [
                    [
                        'type' => 'text',
                        'text' => json_encode($payload, JSON_UNESCAPED_SLASHES),
                    ],
                ],
            ];
        } catch (ValidationException $e) {
            return $this->errorResult('Invalid arguments: '.json_encode($e->errors()));
        } catch (Throwable $e) {
            report($e);

            return $this->errorResult('The tool failed. Retry or use the normal application UI.');
        }
    }

    private function errorResult(string $message): array
    {
        return [
            'isError' => true,
            'content' => [
                [
                    'type' => 'text',
                    'text' => $message,
                ],
            ],
        ];
    }
}

This gives you a single execution path.

Laravel can call the dispatcher directly:

$result = app(ToolDispatcher::class)->call(
    context: ToolContext::fromUser($request->user()),
    name: 'crm.contacts.search',
    arguments: ['email' => $email],
);

n8n can call the same dispatcher through the MCP endpoint.

That is the core win: one implementation, many consumers.


4. Make n8n a boring MCP client

Now for the n8n side.

If your n8n version has a native MCP client node or community node, use it. Point it at your Laravel MCP endpoint and attach credentials.

But do not let the transport distract you. If native MCP support is not available, n8n can still consume the same tools through a simple HTTP Request node.

The request is just JSON-RPC:

{
  "jsonrpc": "2.0",
  "id": "n8n-{{ $execution.id }}-{{ $node['CRM Trigger'].id }}",
  "method": "tools/call",
  "params": {
    "name": "crm.contacts.search",
    "arguments": {
      "email": "={{ $json.email }}"
    }
  }
}

In an n8n HTTP Request node:

Method: POST
URL: https://app.example.com/mcp
Authentication: Generic Credential Type
Header Authorization: Bearer your-n8n-service-token
Content-Type: application/json

Body:

{
  "jsonrpc": "2.0",
  "id": "n8n-{{ $execution.id }}",
  "method": "tools/call",
  "params": {
    "name": "crm.contacts.search",
    "arguments": {
      "email": "={{ $json.email }}"
    }
  }
}

Then unwrap the MCP result in a Code node:

const items = $input.all();

return items.map(item => {
  const response = item.json;

  if (response.error) {
    throw new Error(response.error.message);
  }

  const result = response.result;

  if (result?.isError) {
    const message = result.content?.[0]?.text ?? 'MCP tool failed';
    throw new Error(message);
  }

  const text = result?.content?.[0]?.text ?? '{}';

  return {
    json: JSON.parse(text),
  };
});

This keeps n8n simple.

It does not need to know:

  • how contacts are stored,
  • how tenant scoping works,
  • which fields are allowed,
  • how validation happens,
  • how policies are enforced,
  • or how errors are reported internally.

It only knows the tool name and input schema.

That is a much better boundary for workflow automation.

Listing tools from n8n

You can also call tools/list from n8n for debugging or dynamic workflows:

{
  "jsonrpc": "2.0",
  "id": "n8n-tools-list",
  "method": "tools/list",
  "params": {}
}

This is useful when building generic agent workflows. But be careful: dynamic tool execution should still respect authorization. Do not let a workflow discover tools and then call tools it should not use.

⚠️ Gotcha:
If you expose the MCP endpoint to n8n over the public internet, protect it properly. Use TLS, service tokens, IP allowlists where possible, strict rate limits, and scoped credentials. A workflow automation credential is a high-value credential.


5. Use one authorization model for humans and workflows

This is where many shared-tool setups go wrong.

A Laravel user has permissions. An n8n workflow has a service token. If you treat the workflow as “admin by default,” you have created a dangerous bypass.

Scenario:
An n8n workflow enriches leads and updates CRM records. It uses a token with full access. A bug in the workflow triggers on the wrong tenant and modifies records it should never touch.

Why it matters:
Automation does not reduce the need for authorization. It increases it, because automation can make mistakes at machine speed.

Solution:
Give n8n a service identity with explicit scopes.

In Laravel, you can represent this as a ToolContext.

<?php

namespace App\Mcp;

use App\Models\User;

class ToolContext
{
    public function __construct(
        public ?User $user,
        public string $clientId,
        public array $scopes,
        public ?string $tenantId
    ) {
    }

    public static function fromUser(User $user): self
    {
        return new self(
            user: $user,
            clientId: 'laravel-web',
            scopes: $user->toolScopes(),
            tenantId: $user->tenant_id,
        );
    }

    public static function fromServiceToken($token): self
    {
        return new self(
            user: $token->integrationUser,
            clientId: $token->client_id,
            scopes: $token->abilities,
            tenantId: $token->tenant_id,
        );
    }

    public function canUseTool(string $tool): bool
    {
        return in_array($tool, $this->scopes, true)
            || in_array('*', $this->scopes, true);
    }
}

Then your middleware can build the context:

<?php

namespace App\Http\Middleware;

use App\Mcp\ToolContext;
use Closure;
use Illuminate\Http\Request;

class ResolveToolContext
{
    public function handle(Request $request, Closure $next)
    {
        $token = $request->user()?->currentAccessToken();

        $request->attributes->set('tool_context', $token
            ? ToolContext::fromServiceToken($token)
            : ToolContext::fromUser($request->user())
        );

        return $next($request);
    }
}

And in your request helper:

Request::macro('toolContext', function () {
    return $this->attributes->get('tool_context');
});

The important part is that tools do not assume a human user.

A tool should be able to answer:

  • Who is calling?
  • Is this a user or an integration?
  • Which tenant does this affect?
  • Which scopes are allowed?
  • Can this caller use this tool?
  • Can this caller access this specific record?

For example:

<?php

namespace App\Mcp\Tools\Crm;

use App\Mcp\ToolContext;
use App\Models\Contact;
use Illuminate\Support\Facades\Validator;

class SearchContactsTool
{
    public function handle(ToolContext $context, array $arguments): array
    {
        $validated = Validator::make($arguments, [
            'email' => ['nullable', 'email'],
            'domain' => ['nullable', 'string'],
            'limit' => ['nullable', 'integer', 'min:1', 'max:25'],
        ])->validate();

        return Contact::query()
            ->when($context->tenantId, fn ($query, $tenantId) => $query->where('tenant_id', $tenantId))
            ->when($validated['email'] ?? null, fn ($query, $email) => $query->where('email', $email))
            ->when($validated['domain'] ?? null, fn ($query, $domain) => $query->where('domain', $domain))
            ->limit($validated['limit'] ?? 10)
            ->get(['id', 'name', 'email', 'domain', 'tenant_id'])
            ->toArray();
    }
}

Do not let the caller pass arbitrary tenant context unless the token is explicitly authorized for cross-tenant operations.

A good default:

User token: acts as that user.
n8n service token: acts as a constrained integration user.
Admin token: rare, audited, tightly scoped.

🚨 Production warning:
If n8n can call a tool that bypasses Laravel policies, you have not unified your AI capabilities. You have created a privileged backdoor.


6. Make mutations safe with preview, commit, and idempotency

Read-only tools are easy to share. Mutating tools are where shared MCP architectures get painful.

Scenario:
An n8n workflow creates a support ticket. The HTTP request times out. n8n retries. Laravel actually created the ticket the first time. Now there are two tickets.

Why it matters:
Automation retries are normal. Network failures are normal. Model-driven workflows may call the same tool multiple times. If mutating tools are not idempotent, you will create duplicate side effects.

Solution:
Use three patterns together:

  1. preview,
  2. commit,
  3. idempotency keys.

Preview and commit

For dangerous or visible mutations, split the tool:

support.tickets.create.preview
support.tickets.create.commit

The preview tool validates input and returns a confirmation token:

<?php

namespace App\Mcp\Tools\Support;

use App\Mcp\ToolContext;
use App\Models\Customer;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;

class PreviewTicketTool
{
    public function handle(ToolContext $context, array $arguments): array
    {
        $validated = Validator::make($arguments, [
            'customer_id' => ['required', 'integer', 'exists:customers,id'],
            'subject' => ['required', 'string', 'max:200'],
            'body' => ['required', 'string', 'max:5000'],
            'priority' => ['nullable', 'in:low,normal,high'],
        ])->validate();

        $customer = Customer::query()
            ->when($context->tenantId, fn ($query, $tenantId) => $query->where('tenant_id', $tenantId))
            ->findOrFail($validated['customer_id']);

        $token = (string) Str::uuid();

        Cache::put("ticket-preview:{$token}", [
            'client_id' => $context->clientId,
            'tenant_id' => $context->tenantId,
            'customer_id' => $customer->id,
            'subject' => $validated['subject'],
            'body' => $validated['body'],
            'priority' => $validated['priority'] ?? 'normal',
        ], now()->addMinutes(5));

        return [
            'confirmation_required' => true,
            'confirmation_token' => $token,
            'summary' => [
                'customer_id' => $customer->id,
                'customer_name' => $customer->name,
                'subject' => $validated['subject'],
                'priority' => $validated['priority'] ?? 'normal',
            ],
        ];
    }
}

The commit tool verifies the token and executes the action.

This is especially useful when a human is involved. In Laravel’s product UI, the user can approve the action. In n8n, the workflow can pause for approval, send a Slack message, or require a manual trigger before commit.

Not every mutation needs this pattern. Low-risk internal actions can commit directly. But high-impact actions should not.

Idempotency keys

For commit tools and other mutating tools, require an idempotency key.

Tool schema:

{
  "idempotency_key": {
    "type": "string",
    "maxLength": 64
  }
}

In n8n, use the execution ID or a deterministic hash:

{
  "idempotency_key": "={{ $execution.id }}-support-ticket-create"
}

In Laravel:

$existing = ToolMutationLog::query()
    ->where('client_id', $context->clientId)
    ->where('tool', 'support.tickets.create.commit')
    ->where('idempotency_key', $validated['idempotency_key'])
    ->first();

if ($existing) {
    return json_decode($existing->result_payload, true);
}

After success:

ToolMutationLog::create([
    'client_id' => $context->clientId,
    'tool' => 'support.tickets.create.commit',
    'idempotency_key' => $validated['idempotency_key'],
    'result_payload' => json_encode($result),
    'tenant_id' => $context->tenantId,
]);

This makes retries much safer.

Do not let the model invent idempotency keys

If an AI agent generates the key, it may generate a new one on retry. Prefer deterministic keys from the workflow execution ID, request ID, or business object ID.

For example:

{{ $execution.id }}:create-ticket:{{ $json.customer_id }}

That gives you a stable key for the same logical action.

🧠 The important part:
Shared tools mean shared side effects. If two runtimes can call the same mutation, both need the same retry safety.


7. Test and version MCP tools like a real API

Once n8n and Laravel share MCP tools, those tools are not “internal helpers.” They are an API.

That means you need:

  • contract tests,
  • schema versioning,
  • deprecation strategy,
  • environment separation,
  • observability,
  • and change control.

Contract tests

Test that the tool list is stable:

it('exposes expected MCP tools', function () {
    $response = $this->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 1,
        'method' => 'tools/list',
        'params' => new stdClass(),
    ], [
        'Authorization' => 'Bearer '.$this->serviceToken(),
    ]);

    $response->assertOk()
        ->assertJsonPath('result.tools.0.name', 'crm.contacts.search')
        ->assertJsonPath('result.tools.1.name', 'billing.subscriptions.get');
});

Test that invalid input fails cleanly:

it('rejects invalid contact search input', function () {
    $response = $this->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 2,
        'method' => 'tools/call',
        'params' => [
            'name' => 'crm.contacts.search',
            'arguments' => [
                'limit' => 1000,
            ],
        ],
    ], [
        'Authorization' => 'Bearer '.$this->serviceToken(),
    ]);

    $response->assertOk()
        ->assertJsonPath('result.isError', true);
});

Test authorization:

it('does not expose billing tools to basic n8n tokens', function () {
    $response = $this->postJson('/mcp', [
        'jsonrpc' => '2.0',
        'id' => 3,
        'method' => 'tools/list',
        'params' => new stdClass(),
    ], [
        'Authorization' => 'Bearer '.$this->basicN8nToken(),
    ]);

    $tools = collect($response->json('result.tools'))->pluck('name');

    expect($tools)->not->toContain('billing.subscriptions.get');
});

Version tool schemas carefully

Avoid breaking changes:

  • removing required fields,
  • changing tool names,
  • changing enum values,
  • changing result shape,
  • changing error semantics.

If you must break compatibility, create a new tool name:

support.tickets.create.preview.v2

Or expose a versioned MCP server route:

/mcp/v1
/mcp/v2

Do not silently change behavior because the caller is “just an agent.” Agents and workflows are sensitive to schema changes.

Log tool calls separately

You want to answer questions like:

  • Did this come from n8n or Laravel?
  • Which token called it?
  • Which tenant was affected?
  • Which arguments were provided?
  • Was the call successful?
  • Was it retried?
  • Was it idempotent?
  • How long did it take?

A minimal log entry:

ToolCallLog::create([
    'client_id' => $context->clientId,
    'user_id' => $context->user?->id,
    'tenant_id' => $context->tenantId,
    'tool' => $name,
    'arguments_digest' => hash('sha256', json_encode($arguments)),
    'succeeded' => ! ($result['isError'] ?? false),
    'duration_ms' => $durationMs,
]);

Avoid logging full arguments if they contain sensitive data. Store digests, IDs, and redacted summaries instead.

💡 Practical note:
If you cannot tell whether a mutation came from a human, an n8n workflow, or an in-app agent, your shared tool layer is not production-ready.


8. Choose the right topology: in-process, Laravel MCP server, or standalone gateway

There is more than one way to share MCP tools between n8n and Laravel. The right choice depends on scale, team structure, and how much operational complexity you can absorb.

Topology comparison

TopologyBest whenComplexitySecurity controlOperational burden
Laravel in-process tools onlyLaravel assistant only, no external automationLowHighLow
Laravel MCP endpointn8n and Laravel share domain toolsMediumHighMedium
Standalone MCP gatewayMultiple apps, multiple teams, non-Laravel servicesHigherVariableHigher
n8n workflows exposed as MCP toolsWorkflow-first automations with limited domain rulesMediumMediumMedium

Option 1: Laravel in-process tools only

This is the simplest.

Laravel uses a tool registry and dispatcher internally. No MCP endpoint is exposed.

Use this when:

  • only Laravel needs the tools,
  • n8n is not part of the AI workflow,
  • or n8n only triggers Laravel endpoints.

It is not enough when n8n needs first-class tool access.

Option 2: Laravel MCP endpoint

This is the default I would choose for most products.

Use this when:

  • Laravel owns the domain model,
  • n8n needs to call the same capabilities,
  • you want one validation and authorization layer,
  • and you do not want to run another service yet.

The trade-off is that Laravel becomes a capability server. You need to think about auth, rate limiting, and versioning.

Option 3: Standalone MCP gateway

A separate service can expose MCP tools and call Laravel, external APIs, or other services.

Use this when:

  • multiple applications need the same MCP tools,
  • your organization has platform-team ownership,
  • tools combine several backends,
  • or Laravel should not be the public MCP surface.

The risk is that the gateway becomes a new place to hide business logic. If it needs to call Laravel for every meaningful rule, you may have added complexity without reducing coupling.

Option 4: n8n workflows as MCP tools

Sometimes the workflow itself is the capability.

For example:

ops.run.customer.onboarding
finance.generate.weekly.summary
marketing.sync.crm.segments

If the source of truth is the workflow, exposing it as an MCP tool can make sense.

But be careful. n8n is excellent for orchestration. It is not always the right place for transactional domain rules, authorization, and record-level consistency.

What I would choose

For a Laravel product with n8n automation, I would use:

  1. Laravel as the owner of domain tools.
  2. MCP tool contracts for shared capabilities.
  3. A Laravel MCP endpoint for n8n and internal agents.
  4. n8n as a client, not as the source of domain rules.
  5. A standalone gateway only when multiple services need the same tool layer.

This keeps the boring parts boring: auth, validation, logs, and domain rules stay where they already belong.


A practical checklist before you share tools

Before you let n8n and Laravel share the same MCP tools, walk through this list.

Tool design

  • Tools are named around capabilities, not HTTP routes.
  • Tool descriptions explain when to use and when not to use.
  • Input schemas are strict.
  • additionalProperties is disabled where possible.
  • Read-only tools are separated from mutating tools.
  • Dangerous tools use preview and commit.
  • Tool results are compact and safe to place in model context.

Authorization

  • n8n uses a service token, not a personal admin token.
  • Tokens have scopes.
  • Tools filter by tenant.
  • Record-level authorization is enforced in Laravel.
  • Tool visibility is filtered by caller context.
  • Cross-tenant access is explicitly authorized or blocked.

Reliability

  • Mutating tools accept idempotency keys.
  • Retries do not create duplicate side effects.
  • Timeouts are configured on both n8n and Laravel.
  • Tool errors are structured and safe.
  • Rate limits exist per token and per tool.
  • Long-running operations return job status instead of blocking.

Observability

  • Every tool call logs client, tool, tenant, and outcome.
  • Sensitive arguments are redacted.
  • Execution IDs from n8n are correlated with Laravel logs.
  • Failed tool calls are alertable.
  • Tool latency is monitored.

Versioning

  • Tool schemas are tested.
  • Breaking changes get new tool names or versions.
  • Staging and production use separate credentials.
  • n8n workflows are tested against staging MCP tools.
  • Deprecated tools have a removal plan.

The deeper lesson here is not about MCP syntax.

MCP gives you a standard way to describe tools, but the real win is organizational: one capability, one implementation, many consumers.

When n8n and Laravel both call the same MCP tools, you stop maintaining two versions of your AI system. You get one place to add permissions, one place to fix validation, one place to audit mutations, and one place to evolve the contract.

That is how AI workflows become boring enough for production.

Post a Comment

Previous Post Next Post