One AI Agent, Multiple Tools: Connect Laravel, n8n, RAG, and MCP Without Creating a Mess

The mess doesn't start with a bug. It starts with a second path to the same capability.

First, Laravel calls n8n through a webhook. Then n8n exposes the same workflow as an MCP tool. Then someone adds a Laravel MCP server that wraps the retrieval pipeline, and the agent starts calling both the MCP retriever and a hand-written retriever class. Six weeks later, there are three ways to issue a refund, two ways to search the knowledge base, and no single place that knows which one is authoritative.

Every component still works. That's what makes it a mess: nothing fails loudly enough to force a cleanup. The agent just gets slower, more expensive, and less predictable, and the team gradually stops trusting it.

The fix is not fewer tools. It's a clear ownership rule: each capability has exactly one home, and every other component reaches it through a single contract. Laravel owns the run. n8n owns long-running execution. RAG owns grounded knowledge. MCP owns the tool boundary. When those four statements are true, the tool count stops mattering.

📋 Table of Contents

1. The Mess Is Duplicate Ownership, Not Too Many Tools

Scenario:
The agent has fourteen tools. Three of them can look up an order: a Laravel tool, an n8n workflow exposed over MCP, and a direct database query inside a retrieval tool. The model picks whichever one its description sounds most like. Sometimes it picks the one that doesn't enforce tenant scoping.

Why it matters:
Tool overload is usually described as a context-window problem. The deeper problem is semantic duplication. When two tools do the same thing, the model has no reliable way to know which is authoritative, and neither does the person debugging the run. The duplicate isn't just wasted tokens; it's a second source of truth that can drift.

Solution:
Treat every capability as having one owner. The owner is the component that holds the business rule, the authorization check, and the audit record. Every other component reaches that capability through a single tool definition. If a second path exists, it's either deprecated or it's a different capability with a different name and a different contract.

A simple capability registry makes the ownership explicit:

final class CapabilityRegistry
{
    public const LOOKUP_ORDER = 'orders.lookup';
    public const ISSUE_REFUND = 'orders.refund';
    public const SEARCH_KB = 'knowledge.search';
    public const PROVISION_ACCOUNT = 'accounts.provision';

    public function owner(string $capability): string
    {
        return match ($capability) {
            self::LOOKUP_ORDER => 'laravel',
            self::ISSUE_REFUND => 'laravel',
            self::SEARCH_KB => 'rag-service',
            self::PROVISION_ACCOUNT => 'n8n',
        };
    }
}

Why this works:
The registry turns an architectural decision into a checkable fact. If a new tool claims a capability that already has an owner, the pull request has to either replace the owner or justify a second one. Most duplicates never get written, because the question "who owns this?" is asked before the code exists.

🧠 The important part: The mess is not caused by connecting four systems. It's caused by letting more than one system answer the same question. Ownership is the constraint that keeps the integration from turning into a pile of parallel implementations.

2. Write Down the Ownership Rule Before You Wire Anything

A working division of responsibility looks like this:

ComponentOwnsDoes not own
LaravelAuthentication, tenancy, conversation state, run state, approval policy, audit log, tool selectionLong-running execution, vector indexing, external system retries
n8nMulti-step execution, durable waits, external API orchestration, retry of individual workflow stepsModel reasoning, conversation history, final authorization
RAGChunking, embedding, indexing, retrieval, grounding evidenceWhether the retrieved content is authorized for this user
MCPTool discovery, schema, transport, authentication between client and serverThe business rule inside the tool, the approval gate, the audit record

The rule has one non-negotiable clause: MCP is a boundary, not a home for business logic. A tool exposed over MCP should call into a service that owns the rule, whether that service lives in Laravel, in n8n, or in a standalone retrieval service. The protocol is how the agent reaches the capability. It is not where the capability is defined.

This matters because MCP is now genuinely cross-client. The 2026-07-28 revision made the protocol stateless: the initialize handshake and Mcp-Session-Id are gone, every request carries its protocol version and capabilities in _meta, and servers must implement server/discover. That change is good for scale, but it means the protocol cannot be the place where a multi-step workflow or a human decision is remembered. State has to live somewhere durable, and that somewhere is your application.

💡 Practical note: Put the table above in the repository README, not in a wiki nobody reads. When a new tool is proposed, the first review question should be "which row does this belong to?" If the answer is "two rows," the design isn't ready.

3. Classify Every Tool as Knowledge, Live Data, or Action

Scenario:
A user asks about a refund policy. The agent calls a live order lookup instead of the knowledge base, because the tool descriptions are equally plausible and both mention "refund." The lookup returns an order that belongs to the user, and the model extrapolates a policy from the order status. The answer is wrong in a way that looks authoritative.

Why it matters:
The model chooses tools by description and schema, not by architectural intent. If knowledge tools and action tools have similar descriptions, the model will substitute one for the other. The classification has to be visible in the tool contract, not just in the team's mental model.

Solution:
Classify tools into three types and write descriptions that make the type explicit.

Knowledge tools answer questions from a corpus. They are read-only, they return evidence, and they should say when the corpus has nothing relevant. Live data tools query the current state of a system. They are read-only but authoritative in a different way: they return facts about a specific record, not general guidance. Action tools change state. They are the only tools that require an approval gate.

enum ToolType: string
{
    case Knowledge = 'knowledge';
    case LiveData = 'live_data';
    case Action = 'action';
}

The descriptions should encode the distinction:

public function description(): string
{
    return 'Search the internal knowledge base for policies and procedures. This returns general guidance, not account-specific data. Use the order lookup tool for a specific order.';
}
public function description(): string
{
    return 'Look up the current status and details of a specific order by order ID. Use this only when the user references a specific order. This does not return policy guidance.';
}

Why this works:
The negative clause — "this does not return policy guidance" — gives the model a reason to prefer the other tool when the question is general. The type is not just a label in your registry; it's part of the text the model reads when deciding what to call.

🔍 Why this matters: Duplicate-looking descriptions are the most common cause of wrong-tool selection in multi-tool agents. The fix is not a better model. It's a sharper contract between the two tools, written in the text the model actually sees.

4. Make Laravel the Only Orchestrator

Scenario:
The n8n workflow calls the model directly. The Laravel endpoint also calls the model directly for the same conversation. The two loops share a conversation ID but not a state machine. One of them retries a tool call while the other is still waiting for the first result, and the conversation history ends up with two assistant messages for the same turn.

Why it matters:
Two orchestrators produce two sources of truth for run state. Even if both call the same model with the same tools, they make different decisions about iteration limits, retries, and when to stop. The duplication is invisible until a run takes an unusual path.

Solution:
Laravel owns the agent loop. n8n is invoked as a tool or a job, not as a second orchestrator. The n8n workflow can call models internally for sub-tasks, but the top-level decision about what the agent does next stays in Laravel.

The smallest useful orchestrator is a state machine with a hard iteration budget:

final class AgentOrchestrator
{
    private const MAX_ITERATIONS = 8;

    public function run(AgentRun $run, Agent $agent): Generator
    {
        $iteration = 0;

        while ($iteration < self::MAX_ITERATIONS) {
            $iteration++;
            $run->increment('iteration_count');

            $response = $agent->prompt(
                messages: $run->conversation->messages,
                tools: $this->toolRegistry->for($run),
            );

            if ($response->hasToolCalls()) {
                foreach ($response->toolCalls as $call) {
                    $result = $this->toolExecutor->execute($call, $run);
                    $run->conversation->messages()->create([
                        'role' => 'tool',
                        'tool_call_id' => $call->id,
                        'content' => $result,
                    ]);
                }

                continue;
            }

            yield $response->text;
            return;
        }

        $run->update(['status' => 'iteration_limit_reached']);
    }
}

Why this works:
There is exactly one place that decides when to call another tool and when to stop. n8n can own a long-running action, but it does so as a capability the Laravel loop invokes, not as a competing loop. The run record is the single source of truth for what happened.

🚨 Production warning: If n8n calls the same model provider with the same conversation history, you now have two token bills and two latency profiles for one user request. That's not an orchestration detail. It's a cost and reliability problem that compounds with every duplicate loop.

5. Keep RAG Behind One Tool Contract

Scenario:
The knowledge base is searchable through a Laravel tool, an n8n MCP tool, and a hardcoded retrieval call inside a third agent. The Laravel tool filters by tenant. The n8n tool doesn't. The hardcoded call uses a different embedding model. Retrieval quality depends on which path the model happened to take.

Why it matters:
Retrieval is a capability, not an implementation detail. If every consumer has its own copy of the retrieval logic, the tenant filter, the embedding model, and the similarity threshold will drift. The drift is invisible until a user gets an answer from the wrong corpus.

Solution:
Expose retrieval as one MCP tool, backed by one retrieval service. The service owns chunking, embedding, indexing, and the similarity query. The tool owns the schema and the description. Every agent reaches retrieval through the tool.

Laravel 13 gives you native vector search with whereVectorSimilarTo, and HNSW is the default index when you call index() on a vector column. That makes the retrieval service small enough to live in one class:

final class SearchKnowledgeBase implements Tool
{
    public function description(): string
    {
        return 'Search the internal knowledge base for policies, procedures, and product documentation. Use this before answering any question about policies or account rules. If nothing relevant is found, say so instead of guessing.';
    }

    public function schema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'query' => ['type' => 'string'],
                'source' => [
                    'type' => 'string',
                    'enum' => ['billing', 'technical', 'general'],
                ],
                'limit' => ['type' => 'integer', 'default' => 5],
            ],
            'required' => ['query'],
        ];
    }

    public function handle(ToolInput $input): ToolOutput
    {
        $chunks = KnowledgeChunk::query()
            ->where('tenant_id', $input->tenantId)
            ->when($input->source, fn ($q) => $q->where('source', $input->source))
            ->whereVectorSimilarTo('embedding', $input->query, minSimilarity: 0.35)
            ->limit($input->limit ?? 5)
            ->get();

        if ($chunks->isEmpty()) {
            return ToolOutput::text('No relevant documents found.');
        }

        return ToolOutput::text(
            $chunks->map(fn ($c) => "## {$c->title}\n{$c->content}")->join("\n\n")
        );
    }
}

Why this works:
The tenant filter is in the service, not in the prompt. The embedding model is pinned in one place. The empty-result behavior is explicit, so the model gets a stopping signal instead of an ambiguous blank string. Every consumer of retrieval sees the same contract.

⚠️ Gotcha: A vector index built with vector_l2_ops while you query with cosine distance will be silently ignored, and Postgres will fall back to a full scan. The query still returns results, so the bug looks like a performance problem, not a correctness problem. Check the index operator class against the distance operator you actually use.

6. Let n8n Own Long-Running Execution, Not Agent Reasoning

Scenario:
The agent triggers a provisioning workflow that takes four hours and depends on three external APIs. The workflow is invoked synchronously from Laravel. A queue worker holds the request open, the external API rate-limits, and the job times out. The run is marked failed, but the provisioning workflow is still running on the n8n side.

Why it matters:
n8n is good at durable execution: multi-step workflows, waits, retries, and branching. It is not a model reasoning loop. If you put the agent's decision-making inside n8n, you lose the single orchestrator and you couple the model's iteration budget to the workflow's execution budget. If you put a four-hour workflow inside a synchronous Laravel request, you couple your web server to the slowest external system.

Solution:
n8n owns the long-running action. Laravel invokes it as a tool, records the run, and returns. n8n calls back when the workflow completes. The queue worker is never held across the wait.

final class StartProvisioning implements Tool
{
    public function handle(ToolInput $input): ToolOutput
    {
        $execution = Http::withToken(config('services.n8n.token'))
            ->post(config('services.n8n.provision_webhook'), [
                'run_id' => $input->runId,
                'account_id' => $input->accountId,
                'idempotency_key' => $input->runId . ':' . $input->accountId,
            ])
            ->throw()
            ->json();

        return ToolOutput::text(
            "Provisioning started. Execution ID: {$execution['executionId']}."
        );
    }
}

When the workflow finishes, n8n calls a Laravel callback endpoint, and Laravel dispatches a completion job:

final class CompleteProvisioning implements ShouldQueue
{
    public function handle(): void
    {
        $this->run->update([
            'status' => 'completed',
            'result' => $this->payload['result'],
        ]);
    }
}

Why this works:
The tool returns immediately with an execution handle. The n8n workflow owns the wait and the external retries. The Laravel job boundary is short and retryable, and it never blocks a worker for the duration of the external process.

💡 Practical note: n8n's MCP Server Trigger supports SSE and streamable HTTP but not STDIO, and it authenticates clients with Bearer or header auth. That makes it a good fit for remote workflow-as-tool exposure. It does not make it a good fit for the agent's main loop, because the trigger is designed for tool invocation, not for conversation state.

7. Use MCP as the Boundary, Not the Business Logic

Scenario:
The team builds an MCP server for every integration. The refund tool, the order lookup tool, and the knowledge search tool are all MCP servers. Each one has its own auth, its own deployment, and its own copy of the tenant check. The tenant check drifts between servers. One of them forgets it entirely.

Why it matters:
MCP is a protocol, not an architecture. It standardizes discovery, schema, transport, and authentication. It does not standardize the business rule, the approval policy, or the audit record. If you put the business logic in the MCP server, you now have as many copies of the rule as you have servers.

Solution:
MCP servers should be thin adapters over services that own the rule. The server exposes the capability; the service enforces the policy. For Laravel-native tools, the AI SDK's MCP client lets you pull external tools into an agent with a single line, alongside hand-written tools:

public function tools(): array
{
    return [
        new SearchKnowledgeBase(),
        new LookUpOrder(),
        ...Mcp::client('billing')
            ->withToken(config('services.billing.token'))
            ->tools(),
    ];
}

The MCP client lives in laravel/mcp and supports STDIO for local servers and streamable HTTP for remote servers. The AI SDK integration wraps each MCP tool to fit the agent's tool contract, so the agent doesn't know or care which tools came from where.

Why this works:
The boundary is consistent. The agent sees one tool array. The MCP client handles transport, version negotiation, and authentication. The service behind each tool owns the rule. If a new MCP server is added, it plugs into the same array, and the approval policy applies to it the same way it applies to a hand-written tool.

🚨 Production warning: Every MCP server you connect is a new trust boundary. Tool descriptions are part of the model's prompt, and a malicious server can embed instructions in its metadata. Pin tool descriptions by hash, review new servers manually, and never auto-approve a write tool because the server labeled it safe.

8. The MCP Stateless Revision Changes Who Owns State

Scenario:
The MCP client was built against the 2025-11-25 specification. It calls initialize, stores a session ID, and assumes the server remembers the previous call. The server implements the 2026-07-28 revision. The handshake is rejected, the session ID is ignored, and the error looks like a tool failure rather than a protocol mismatch.

Why it matters:
The stateless revision removed protocol-level sessions and the Mcp-Session-Id header. List endpoints no longer vary per connection. Servers that need cross-call state must use explicit, server-minted handles passed as ordinary tool arguments. If your integration relies on implicit session state, it will break silently or fail at negotiation.

Solution:
Treat state as application state. If a tool needs to remember something across calls, the server returns a handle and the client passes it back as an argument. The conversation or run record stores the handle.

final class ProvisioningState
{
    public function __construct(
        public readonly string $runId,
        public ?string $executionHandle = null,
    ) {}
}

The tool returns the handle:

{
  "status": "started",
  "handle": "exec_01J8Z4K9"
}

The next call includes it:

{
  "handle": "exec_01J8Z4K9",
  "action": "check_status"
}

Why this works:
The stateless core is good for scale: any request can land on any instance, and the instance has everything it needs. But it means the protocol cannot be the place where a human decision or a multi-step workflow is remembered. That state has to live in Laravel, where it can be persisted, resumed, and audited.

🧠 The important part: Pin your MCP client and server dependencies to exact minor versions. The stateless revision created two protocol eras. A composer update that silently moves you across that boundary will break integrations in ways that unit tests won't catch until runtime.

9. Budget the Tool Surface Before It Budgets You

Scenario:
The agent starts with four tools and works well. Six months later it has eighteen tools across five MCP servers. Tool selection accuracy drops, latency climbs, and the prompt is crowded with schemas the agent rarely uses. The team adds more prompt instructions to fix the selection problem, which makes the prompt longer and the problem worse.

Why it matters:
Tool definitions consume context before the user says anything. More tools mean more options competing for attention, and tool selection accuracy degrades as the option space grows. The cost is not just tokens; it's the model's ability to choose correctly.

Solution:
Budget the tool surface the same way you budget a database index. Scope tools per agent role, load only the tools relevant to the current intent, and use a tool-search pattern for large inventories.

A role-scoped registry:

final class ToolRegistry
{
    public function for(string $role): array
    {
        return match ($role) {
            'support' => [
                new SearchKnowledgeBase(),
                new LookUpOrder(),
                ...Mcp::client('billing')->tools(),
            ],
            'deployment' => [
                ...Mcp::client('github')->tools(),
                ...Mcp::client('monitoring')->tools(),
            ],
            default => [],
        };
    }
}

For larger inventories, a tool-search tool is more scalable than loading every schema upfront:

class FindTools extends Tool
{
    public function description(): string
    {
        return 'Search available tools by capability. Call this before using a tool you have not used in this session.';
    }

    public function handle(ToolInput $input): ToolOutput
    {
        return ToolOutput::text(
            ToolIndex::search($input->capability, limit: 5)->toJson()
        );
    }
}

Why this works:
The agent gets the tools it needs for the current role, not the union of every capability in the organization. The tool-search pattern trades one extra call for a much smaller baseline prompt, which is usually the right trade when the inventory is large.

🔍 Why this matters: The "more tools equals more capable" assumption is the most common multi-tool design mistake. Capability is not the sum of available tools. It's the model's ability to choose the right tool at the right time, and that ability degrades as the option space grows.

10. Design the Failure Path Before the Happy Path

Scenario:
The n8n workflow times out. The MCP server returns a schema validation error. The RAG tool returns an empty result. Laravel reports success because the HTTP response was 200. The user gets a plausible but wrong answer, and nothing in the system flags it.

Why it matters:
Every component in this stack can fail without throwing. n8n can complete a workflow that produced a bad result. MCP can return a structurally valid but semantically empty payload. RAG can return chunks that don't answer the question. Laravel can record a 200 because the transport succeeded. The failure is semantic, and semantic failures don't have stack traces.

Solution:
Validate at the boundary between components, and make the validation deterministic. Check that the tool result is structurally valid, that the retrieved context is non-empty, and that the agent didn't hit its iteration limit. Convert semantic failures into exceptions so the normal retry and failure paths apply.

final class ValidateToolResult
{
    public function validate(string $tool, array $result): void
    {
        if ($result === [] || ($result['items'] ?? null) === []) {
            throw new EmptyToolResult($tool);
        }

        if (in_array($result['status'] ?? null, ['degraded', 'partial'], true)) {
            throw new DegradedToolResult($tool);
        }
    }
}

And validate the run before marking it complete:

final class ValidateRun
{
    public function validate(AgentRun $run): void
    {
        if ($run->iteration_count >= 8) {
            throw new AgentIterationLimitExceeded();
        }

        if (blank($run->final_answer)) {
            throw new EmptyAgentAnswer();
        }
    }
}

Why this works:
Semantic failures become exceptions, and exceptions become retries or terminal failures. The retry gets a fresh tool call, which may return a different result. If the failure is deterministic, the run lands in a failed state where a human can inspect it.

⚠️ Gotcha: n8n's AI Agent V3 node has a documented interaction between retryOnFail and maxIterations. When the node hits the iteration limit, the node-level retry re-invokes execute() without the accumulated tool-call history, and the retry budget resets each cycle. The documented workaround is to disable retryOnFail on Agent V3 nodes and route the error through continueErrorOutput. A green execution that restarted three times is not a success.

11. One Trace Across Four Systems

Scenario:
A user reports a wrong answer. You open Laravel's log, n8n's execution history, the MCP server's logs, and the vector search metrics. You have four timelines and no way to prove they belong to the same run. You spend an hour correlating by timestamp.

Why it matters:
A multi-component agent produces a distributed trace whether or not you instrument it. If you don't, the trace exists only as four disconnected logs. Debugging becomes archaeology, and the root cause stays hidden behind a plausible-looking output.

Solution:
Generate one correlation ID at the Laravel entry point and propagate it through every hop: the n8n webhook payload, the MCP request _meta, the retrieval span attributes, and the callback. Persist every step as a structured event on the run.

$correlationId = (string) Str::ulid();

$run = AgentRun::create([
    'id' => $correlationId,
    'conversation_id' => $request->conversation_id,
    'status' => 'pending',
]);

Http::withHeaders(['X-Correlation-ID' => $correlationId])
    ->post(config('services.n8n.agent_webhook'), [
        'run_id' => $correlationId,
        'callback_url' => route('api.agent-runs.callback', $run),
    ]);

In n8n, tag the execution with the same ID using the Execution Data node. In MCP, pass it in the request metadata. In RAG, record the retrieved chunk IDs and similarity scores alongside the correlation ID.

Why this works:
The correlation ID turns four telemetry islands into one trace. When a bad answer is reported, you search one ID and see the Laravel request, the n8n execution, the MCP calls, and the retrieval results in order. The debugging question changes from "which log should I check?" to "which step in this trace produced the wrong evidence?"

💡 Practical note: Propagate the ID in both headers and payloads. Headers get dropped by proxies and rewritten by gateways. A payload field survives serialization and is visible in the execution history even when the header is gone.

The Architecture Decision Guide

The integration is not a single architectural choice. It's a set of boundaries, and each boundary has a correct owner.

QuestionCorrect ownerWrong ownerWhy it matters
Who decides when to call another tool?Laravel orchestratorn8n, MCP server, model promptOne loop means one iteration budget and one source of truth for run state
Who enforces tenant scoping?The service behind the tool, in codeThe model, the prompt, the MCP server's metadataThe model has no reliable concept of identity
Who owns long-running execution?n8nLaravel queue worker, MCP serverWorkers shouldn't block across waits; MCP isn't a workflow engine
Who owns retrieval quality?The RAG serviceEach agent, each tool consumerEmbedding model, chunking, and thresholds must not drift
Who owns the approval decision?Laravel, persisted as a rowThe model, the chat UI, the MCP serverAn approval that isn't durable isn't a control
Who owns state across calls?The application, as an explicit handleThe MCP sessionThe 2026-07-28 revision removed protocol-level sessions

The implementation order I'd follow:

First, write the ownership table. Before any code, agree on which component owns each capability. This is the decision that prevents the mess.

Second, build the Laravel orchestrator and the run record. The run is the trace, the audit log, and the resume point. Build it before the first MCP server.

Third, expose retrieval as one MCP tool. One schema, one tenant filter, one embedding model. Every agent uses it.

Fourth, move long-running execution to n8n. Invoke it as a tool, return an execution handle, and let the callback complete the run.

Fifth, add the approval gate for action tools. Read tools run inline. Actions pause. The policy lives in Laravel, not in the tool description.

Sixth, add the correlation ID and the semantic validators. Without them, you can't tell a correct run from a plausible one.

The final observation is the one that keeps the architecture from collapsing back into a mess: the protocol is not the architecture. MCP gives you a clean boundary, but the boundary is only as good as the service behind it. n8n gives you durable execution, but execution is only useful if the orchestrator knows when to invoke it. RAG gives you knowledge, but knowledge is only grounded if the retrieval path is single. Laravel doesn't win because it's the framework. It wins because it's the only component that can see the whole run, and someone has to.

The test I'd run before trusting the integration is simple. Ask a policy question and verify the agent calls the knowledge tool, not the live-data tool. Ask an account-specific question and verify it calls the live-data tool, not the knowledge tool. Trigger an action and verify the approval gate fires. Kill a queue worker mid-run and verify the run is resumable. If all four pass without the model being told which system to use, the boundaries are real. If any of them fail, you have four working components and one broken system, which is exactly where the mess started.

Post a Comment

Previous Post Next Post