Every dashboard is green. Every unit test passes. The n8n execution history shows completed workflows. The MCP server responds to tools/list without error. The vector search returns five chunks with healthy similarity scores. Laravel writes a 200 response to the log.
And the answer is wrong.
Not wrong in a way that throws an exception. Wrong in a way that a customer notices three days later, when they’ve been quoted a return window that doesn’t exist, or a refund amount that belongs to a different order. There is no stack trace for this failure. There is no failed node to inspect. The system did exactly what it was built to do, and what it was built to do was subtly broken at the seams.
This is the integration failure mode of a multi-component AI agent. Each piece is correct in isolation. The failure lives in the boundaries between them: the correlation ID that was never propagated, the tool schema that drifted after a deploy, the MCP session that vanished when the protocol went stateless, the retrieved chunk that was plausible but wrong. Debugging it requires a different discipline than debugging code. You have to debug the trajectory, not the exception.
📋 Table of Contents
- 1. Every Component Passes; the System Fails
- 2. The First Bug Is the Missing Correlation ID
- 3. n8n’s “Success” Is Not Your Success
- 4. MCP Went Stateless and Your Debugging Assumptions Didn’t
- 5. RAG Fails Silently by Design
- 6. Laravel Is the Only Place That Sees the Whole Run
- 7. The Silent Success: When 200 OK Is the Bug
- 8. Tool Schema Mismatches Look Like Model Mistakes
- 9. Context Poisoning and the Cascade You Can’t See
- 10. The Debugging Playbook: Five Traces to Read in Order
1. Every Component Passes; the System Fails
Scenario:
Your Laravel test suite mocks the AI SDK and passes. Your n8n workflows have green execution history. Your MCP server passes the Inspector’s tool listing check. Your RAG evaluation suite scores 0.91 on retrieval precision. In production, a user asks a question that should be answered from a knowledge base article, and the agent calls a live order-lookup tool instead. The tool returns an order from a different tenant. The model summarizes it politely.
Why it matters:
Component tests verify that each piece works against its own contract. They don’t verify that the contracts compose. The n8n workflow doesn’t know that the MCP tool’s schema changed. The RAG retriever doesn’t know that the agent had already retrieved the same article twice. The Laravel orchestrator doesn’t know that the MCP server is speaking a protocol version that dropped the handshake it was built against. Each component is healthy. The system is not.
Solution:
Stop treating this as a bug in one component. Treat it as a trajectory problem, and instrument the trajectory end to end. A trajectory is the ordered sequence of decisions, retrievals, tool calls, and model outputs that produced the answer. You can’t debug what you didn’t record, and most multi-component agents record only the final output and the exceptions.
Why this works:
The trajectory is the only artifact that spans all four components. A single trace ID that travels from the Laravel request through the n8n execution, the MCP tool call, and the RAG retrieval lets you reconstruct what each component saw and decided. Without it, you’re correlating logs by timestamp and hope.
🧠 The important part: In agentic systems, the trace is the source of truth for what the system actually did, as opposed to what the code says it should do. Debugging means reading the trace, not reading the code.
2. The First Bug Is the Missing Correlation ID
Scenario:
A user reports a wrong answer. You open the Laravel logs and find the request. You open n8n’s execution history and find an execution that started around the same time. You open the MCP server logs and find a tool call with a timestamp that’s close. You have three logs from three systems, and no way to prove they belong to the same run. You spend an hour reconstructing the timeline by hand.
Why it matters:
n8n has no native execution correlation across workflows. Its execution ID is local to n8n. The MCP server has its own request IDs. Laravel has its own request ID. None of them are the same string, and none of them are propagated to the next hop. Every debugging session starts with correlation by timestamps, which is guesswork dressed up as analysis.
Solution:
Generate one correlation ID at the Laravel entry point, propagate it through every hop, and attach it to every log line and span. In n8n, use the Execution Data node to tag the execution with the correlation ID. In MCP, pass it in the request _meta. In RAG, include it in the retrieval span attributes.
final class StartAgentRun
{
public function handle(Request $request): AgentRun
{
$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.webhook'), [
'run_id' => $correlationId,
'callback_url' => route('api.agent-runs.callback', $run),
]);
return $run;
}
}
In n8n, the Execution Data node writes the ID onto the execution so you can filter for it later:
{
"name": "Tag Execution",
"type": "n8n-nodes-base.executionData",
"parameters": {
"operation": "set",
"data": {
"values": [
{ "key": "correlation_id", "value": "={{ $json.run_id }}" }
]
}
}
}
Why this works:
The correlation ID turns four disconnected telemetry islands into one distributed trace. When a user reports a bad answer, you search one ID and see the Laravel request, the n8n execution, the MCP tool calls, and the retrieval spans in order. Debugging stops being archaeology.
💡 Practical note: Propagate the ID in headers and in 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.
3. n8n’s “Success” Is Not Your Success
Scenario:
An n8n workflow completes with a green checkmark. The execution history shows every node succeeded. The agent’s final message is delivered to Laravel’s callback endpoint. But the message is a hallucination, because the agent looped eight times, exhausted its tool budget, and produced a plausible-sounding answer from stale context. n8n reports success because no node threw.
Why it matters:
n8n’s execution status reflects node-level outcomes, not semantic correctness. A workflow can succeed at the transport layer and fail at the task layer. The AI Agent node has its own iteration limits, and when those limits are hit, the failure doesn’t always surface as a node error. It surfaces as a completed execution with a bad output.
Solution:
Add a validation step between n8n’s completion and Laravel’s success state. The validation should be deterministic: check that the output is structurally valid, that the retrieved context is non-empty, and that the agent didn’t hit its iteration cap. If validation fails, Laravel should throw a domain exception and apply its normal retry policy.
final class ValidateAgentResult
{
public function validate(array $payload): void
{
if (($payload['iterations'] ?? 0) >= 8) {
throw new AgentIterationLimitExceeded();
}
if (empty($payload['retrieved_chunks'])) {
throw new AgentAnsweredWithoutContext();
}
if (blank($payload['answer'] ?? null)) {
throw new EmptyAgentAnswer();
}
}
}
Why this works:
The validator moves the definition of “success” from the workflow engine to the application. n8n says the workflow ran. Laravel says the result is usable. Those are different claims, and only the second one matters to the user.
🚨 Production warning: n8n’s AI Agent V3 node has a documented interaction between
retryOnFailand the agent’s iteration limit. When the node hitsmaxIterations, the generic retry mechanism re-invokesexecute()without the accumulated tool-call history, effectively restarting the task from scratch. The retry budget resets each cycle, so the loop can repeat indefinitely. The workaround is to disableretryOnFailon Agent V3 nodes and route the error throughcontinueErrorOutputto a failure callback. A green execution that restarted three times is not a success.
4. MCP Went Stateless and Your Debugging Assumptions Didn’t
Scenario:
You built the MCP client against the 2025-11-25 specification. It calls initialize, stores a session ID, and assumes the server remembers the tool list between calls. You upgrade to a server that implements the 2026-07-28 revision. The handshake is rejected, the session ID is ignored, and the server returns -32022 or a plain method-not-found. The error looks like a tool failure, but it’s a protocol-era mismatch.
Why it matters:
The 2026-07-28 MCP revision was the largest change to the protocol since launch. It removed protocol-level sessions and the Mcp-Session-Id header. It removed the initialize/notifications/initialized handshake. It removed stream resumability and the Last-Event-ID header. Every request now carries its protocol version and client capabilities in _meta. A server that speaks the new revision will reject a client that opens with the old handshake, and the error doesn’t say “protocol mismatch.” It says the method doesn’t exist.
Solution:
Make the protocol version explicit in your debugging. When an MCP call fails, check the negotiated version before you check the tool schema. The MCP Inspector is the first stop for interactive debugging: connect to the server over stdio or streamable HTTP, list tools, invoke one, and watch the notification stream. If the Inspector works and your client doesn’t, the problem is in your client’s protocol handling, not the server.
npx @modelcontextprotocol/inspector \
--transport http \
--url https://mcp.example.com/mcp
For automated checks, a protocol conformance tool can tell you whether a server implements server/discover, whether it accepts stateless requests, and which breaking issues remain:
npx mcp-spec-check --url https://mcp.example.com/mcp
Why this works:
The protocol version is the first thing to verify because a mismatch invalidates every downstream assumption. If the client and server disagree on whether sessions exist, nothing else about the call is trustworthy. The Inspector and conformance tools surface the mismatch in seconds, instead of letting you debug a schema validation error that’s actually a handshake rejection.
⚠️ Gotcha: Stream resumability is gone in the stateless revision. A dropped SSE stream now loses the in-flight request outright. Your client has to re-issue the request with a new request ID. For idempotent reads, that’s harmless. For tools that charge a card or send an email, a retried request is a duplicated side effect. The spec doesn’t solve it for you; the application needs an idempotency key as a tool parameter.
5. RAG Fails Silently by Design
Scenario:
The retriever returns five chunks. The similarity scores are 0.71, 0.68, 0.65, 0.61, and 0.59. The scores look healthy. But the chunk that actually answers the question scored 0.38 and was filtered out by the minSimilarity threshold. The model generates an answer from the five chunks it received, and the answer is confidently wrong. No error is thrown. The retrieval pipeline looks clean.
Why it matters:
RAG failures are the hardest class to debug because the pipeline always returns something. Wrong chunks, right chunks in the wrong order, stale chunks, and chunks that split the answer across two documents all produce fluent outputs. The traces look nearly identical whether the answer is right or wrong, unless you capture what was actually retrieved and score it separately from the generation.
Solution:
Instrument retrieval and generation as separate spans, and record the retrieved chunk IDs, scores, and content. The most common production bug is “the retriever returned bad chunks but the generator made the answer sound fine.” A single combined span hides that bug. Two spans expose it.
final class TracedRetriever
{
public function retrieve(string $query): Collection
{
return $this->tracer->span('rag.retrieval', function (Span $span) use ($query) {
$chunks = KnowledgeChunk::query()
->whereVectorSimilarTo('embedding', $query, minSimilarity: 0.3)
->limit(10)
->get();
$span->setAttributes([
'retrieval.query' => $query,
'retrieval.chunk_ids' => $chunks->pluck('id')->all(),
'retrieval.scores' => $chunks->pluck('similarity')->all(),
'retrieval.count' => $chunks->count(),
]);
return $chunks;
});
}
}
Then score the generation against the retrieved evidence:
$span->setAttributes([
'generation.grounded' => $this->isGrounded($answer, $chunks),
'generation.citations' => $this->extractCitations($answer),
]);
Why this works:
Separate spans let you answer the first debugging question: did the retriever fail, or did the generator ignore what it was given? If the retrieved chunk IDs don’t include the correct document, the problem is retrieval. If they do, and the answer still doesn’t cite it, the problem is generation. Those are different fixes.
🔍 Why this matters: One production analysis of RAG failures found that most traced back to indexing and retrieval rather than the language model. The retriever is the first place to look, and it’s the stage that breaks most often. But you can only see it if you record what was retrieved, not just what was generated.
6. Laravel Is the Only Place That Sees the Whole Run
Scenario:
The n8n execution logs show a tool call. The MCP server logs show a validation error. The RAG logs show an empty result. The Laravel log shows a 200 response. None of the four logs mention the others. The only place where all four events exist in the same request context is the Laravel run record, and it’s empty because the orchestrator only logs the final answer.
Why it matters:
Laravel is the entry point and the callback target. It’s the only component that sees the run from start to finish. If it doesn’t persist the intermediate steps, no other component can reconstruct them. n8n doesn’t know about the MCP error. The MCP server doesn’t know about the RAG miss. The RAG pipeline doesn’t know about the n8n retry. Laravel is the integration point, and the integration point is where the debugging data has to live.
Solution:
Record every step as a structured event on the run, and use Laravel AI SDK’s lifecycle events to capture the agent loop. Since v0.11.0, every run has a single invocation ID, and every provider round-trip and tool call reports a start and an end with wall timings. StartingStep, StepCompleted, and StepFailed fire around each round-trip, carrying the messages sent, the response, the token usage, and the finish reason.
Event::listen(StepCompleted::class, function (StepCompleted $event) {
AgentRunEvent::create([
'run_id' => $event->invocationId,
'step' => $event->stepNumber,
'duration_ms' => $event->time,
'prompt_tokens' => $event->response->usage->promptTokens,
'completion_tokens' => $event->response->usage->completionTokens,
'finish_reason' => $event->response->finishReason->value,
'tool_calls' => $event->response->toolCalls->toArray(),
]);
});
For distributed tracing, export the same events as OpenTelemetry spans with W3C trace context so they join the n8n and MCP spans into one trace tree:
Event::listen(StartingStep::class, function (StartingStep $event) {
$span = $this->tracer->startSpan('ai.step', [
'gen_ai.request.model' => $event->model,
'ai.step_number' => $event->stepNumber,
'ai.is_final_step' => $event->isFinalStep,
]);
$this->activeSpans[$event->stepNumber] = $span;
});
Why this works:
The run record becomes the single source of truth for the trajectory. When a user reports a bad answer, you read the run’s events in order and see the retrieval, the tool call, the model step, and the callback. You don’t need to correlate four logs by timestamp, because the correlation is already in the record.
💡 Practical note: Privacy-safe tracing means recording metadata, not payloads. The
a2a-otel-kitapproach captures trace IDs, span IDs, operation names, and allowlisted scalar attributes, but explicitly drops prompts, model responses, tool arguments, and credentials. You can debug a trajectory without storing the customer’s data.
7. The Silent Success: When 200 OK Is the Bug
Scenario:
A tool returns {}. No exception. The model treats the empty object as a valid result, fills the gap with parametric knowledge, and produces a fluent answer. The trace shows a successful tool call with a 200 status. The answer is wrong. Nothing in the system flagged it.
Why it matters:
AI agents fail through semantic breakdown, not HTTP errors. A tool that returns an empty result, a placeholder, or a degraded response is more dangerous than a tool that throws, because the throwing tool triggers the error path and the empty one doesn’t. The pipeline keeps running, broken.
Solution:
Validate tool outputs before they reach the model. Check for required fields, empty collections, placeholder values, and refusals. If the output is structurally valid but semantically empty, convert it into an explicit error that the agent can reason about.
final class ValidateToolOutput
{
public function validate(string $toolName, array $output): void
{
if ($output === []) {
throw new EmptyToolResult($toolName);
}
if (isset($output['items']) && $output['items'] === []) {
throw new EmptyToolResult($toolName);
}
if (in_array($output['status'] ?? null, ['degraded', 'partial'], true)) {
throw new DegradedToolResult($toolName);
}
}
}
Why this works:
The validator turns a silent semantic failure into a loud structural failure. The agent receives an error instead of an empty object, and the error is a signal it can use. If the error is recoverable, the agent retries with different arguments. If it’s not, the run fails cleanly and a human can inspect it.
🚨 Production warning: The most expensive silent failure is the hallucinated tool retry loop. An agent calls a tool, gets a schema validation error, slightly tweaks the argument, and retries. Prometheus shows 15% CPU and zero server errors while the agent burns through API credits in a loop. Per-span token logging and a per-run cost cap are the only way to catch it before the invoice arrives.
8. Tool Schema Mismatches Look Like Model Mistakes
Scenario:
The agent calls a tool with a customer_id field. The MCP server expects customerId. The server returns a validation error. The model sees the error, tries again with a different guess, and eventually gives up and answers from memory. The trace shows a series of tool calls with validation errors, and the final answer looks like a model reasoning failure.
Why it matters:
Schema mismatches are almost always an integration bug, not a model bug. The tool description was written for one schema, the server validates against another, and the model is caught in the middle. Debugging the model’s “reasoning” is the wrong fix, because the model is responding correctly to an inconsistent contract.
Solution:
Validate the tool schema at the boundary, before the model ever sees it. Use the MCP Inspector to list tools and inspect their inputSchema. Check that the top-level type is "object", that required fields are spelled correctly, and that the schema matches what the server actually validates. Then add a contract test in Laravel that compares the advertised schema to the expected schema and fails the build on drift.
it('advertises the expected schema for lookup_invoice', function () {
$tools = Mcp::client('billing')->tools();
$schema = collect($tools)->firstWhere('name', 'lookup_invoice')['inputSchema'];
expect($schema['required'])->toBe(['invoice_id']);
expect($schema['properties'])->toHaveKey('invoice_id');
});
Why this works:
The contract test catches drift at build time, not at runtime. If a server deploy renames a field or changes a type, the test fails before the agent starts producing validation errors in production. The Inspector catches the same problem interactively during development.
⚠️ Gotcha: The MCP Inspector’s CLI mode has been known to silently accept invalid JSON Schema constructs that actual clients reject. If the Inspector lists a tool successfully and your client still fails to call it, the schema may be invalid in a way the Inspector didn’t check. Validate against JSON Schema Draft-07 or 2020-12 explicitly.
9. Context Poisoning and the Cascade You Can’t See
Scenario:
A RAG pipeline retrieves a chunk that contains a plausible but incorrect fact. The model incorporates it into the answer. The next turn, the incorrect fact is in the conversation history. The model treats it as established context and builds on it. By the third turn, the answer is confidently wrong and internally consistent, and no single step looks like the failure.
Why it matters:
Context poisoning happens when a hallucination or error enters the context and gets repeatedly referenced, compounding over time. In a multi-agent pipeline, the effect cascades: a slight error from Agent A becomes accepted ground truth for Agent B, which produces a conclusion that Agent C elaborates. The final output is well-formed and wrong, and the trace shows every component operating “correctly.”
Solution:
Detect poisoning at the point of entry, not at the point of output. Score retrieved chunks for groundedness before they enter the context, and flag answers that cite no retrieved evidence. Record the provenance of every fact in the conversation so you can trace a wrong claim back to the chunk that introduced it.
final class GroundingCheck
{
public function isGrounded(string $answer, Collection $chunks): bool
{
$citations = $this->extractCitations($answer);
if ($citations->isEmpty()) {
return false;
}
$chunkIds = $chunks->pluck('id');
return $citations->every(fn ($id) => $chunkIds->contains($id));
}
}
Why this works:
The grounding check separates “the model produced an answer” from “the model produced an answer supported by the retrieved evidence.” A citation to a chunk that wasn’t retrieved, or no citation at all, is a poisoning signal. Flagging it early stops the error from entering the conversation history and cascading.
🧠 The important part: Context poisoning is not a model bug. It’s a data-flow bug. The fix is to control what enters the context, not to ask the model to be more careful. Sanitize on retrieval, validate on entry, and record provenance so you can trace the error back to its source.
10. The Debugging Playbook: Five Traces to Read in Order
When the system fails and every component claims success, read the traces in this order. Each step eliminates a class of failure before you move to the next.
First, the correlation ID. If you can’t join the Laravel request, the n8n execution, the MCP calls, and the RAG retrievals into one trace, stop and fix the propagation. Every subsequent step depends on it.
Second, the protocol version. For any MCP failure, verify that the client and server agree on the 2026-07-28 stateless revision before you inspect anything else. A handshake mismatch looks like a tool error and isn’t.
Third, the retrieval span. Check what was retrieved, not just that retrieval succeeded. Wrong chunk IDs, low scores, empty results, and vocabulary mismatches are the most common cause of wrong answers. If the right chunk wasn’t retrieved, the model never had a chance.
Fourth, the tool span. Check the schema, the arguments, and the raw result. A validation error that repeats across retries is a contract mismatch, not a reasoning failure. An empty result that the model treated as valid is a silent success, not a successful call.
Fifth, the generation span. Only after the first four are clean should you look at the model’s reasoning. Check groundedness, citation coverage, finish reason, and token usage. A model that answered without retrieved evidence or ignored a tool result is a generation problem, and it’s the last place to look, not the first.
The pattern is a process of elimination, not a search for a culprit. Each trace answers a specific question: was the run correlated, was the protocol compatible, was the evidence retrieved, was the tool result valid, and was the answer grounded. The failure is the first question that gets a “no.”
A production checklist for the whole system:
| Check | What it catches | Where it lives |
|---|---|---|
| Correlation ID on every hop | Unjoinable telemetry | Laravel entry point, n8n Execution Data, MCP _meta |
| Protocol version negotiation | Stateless/stateful mismatch | MCP client and server, Inspector |
| Retrieval span with chunk IDs | Wrong or missing chunks | RAG retriever |
| Tool schema contract test | Drift after deploy | CI, MCP Inspector |
| Tool output validation | Silent empty results | Laravel orchestrator |
| Grounding check | Ungrounded answers, poisoning | Laravel generation span |
| Per-run cost and token cap | Runaway retry loops | Laravel job, observability |
I wouldn’t build all of this on day one. I’d start with the correlation ID and the retrieval span, because those two catch the majority of integration failures and they’re the foundation for everything else. Then the protocol check, because the stateless revision is a breaking change that produces misleading errors. Then tool validation and grounding. The order matters because each layer depends on the one before it.
The system that works separately and fails together is not a system with four broken components. It’s a system with one broken boundary. The debugging discipline is to find the boundary, not to blame the component. And the boundary is always visible in the trace, if you recorded it.