Most AI agent tutorials follow the same fragile script: a thirty-line Python script wrapping LangChain that queries an OpenAI endpoint, executes an arbitrary shell command, and crashes the moment two concurrent users connect. In the enterprise world, you cannot ship a terminal script wrapped in Streamlit. You have relational data, session persistence, role-based access control, queue workers, compliance boundaries, and frontends that must render streaming output reliably.
Over the past several months, I designed and deployed a production-grade full-stack AI agent architecture built on top of Laravel 11+, Vue 3, Anthropic’s Model Context Protocol (MCP), and a custom Retrieval-Augmented Generation (RAG) pipeline backed by PostgreSQL and pgvector.
The system does not rely on opaque third-party agent frameworks. Instead, it leverages Laravel as a resilient state machine and orchestration engine, uses MCP to decouple tool execution into isolated micro-processes, utilizes hybrid vector/keyword search for deterministic grounding, and streams real-time execution graphs directly to a reactive Vue 3 interface via Server-Sent Events (SSE).
Here is the exact architectural blueprint, the engineering trade-offs, and the production-tested code required to build it.
📋 Table of Contents
- The Architectural Topology
- 1. The Agent State Machine: Taming Non-Deterministic Loops
- 2. Building an MCP Client in PHP: Standardizing Tool Execution
- 3. Production RAG: Hybrid Search with pgvector and Reciprocal Rank Fusion
- 4. Managing Context Windows, Token Drift, and System Prompts
- 5. Real-Time Streaming: SSE, Nginx Buffering, and Tool Telemetry
- 6. The Vue 3 Client: Handling Concurrent Streams and Tool States
- 7. Hardening Security: Sandboxing, Prompt Injection, and Tool RBAC
- Architectural Comparison: Custom Stack vs. Monolithic Agent Frameworks
- The Production Operational Checklist
The Architectural Topology
Before diving into code, we must clarify what an AI agent actually is in an enterprise context. An agent is not a magical entity; it is a while-loop driven by an LLM evaluating whether it has sufficient context to answer a user's prompt or whether it must request external tool execution to gather more information.
The following diagram illustrates the flow of a single interaction through our stack:
┌─────────────────────────────────────────────────────────────┐ │ Vue 3 Frontend │ │ (Pinia Store, SSE EventSource Reader, Reactive UI Tree) │ └──────────────┬──────────────────────────────▲───────────────┘ │ 1. POST /api/agent/chat │ 7. SSE Event Stream ▼ │ (Tokens, Tool Status) ┌──────────────────────────────────────────────┴──────────────┐ │ Laravel Orchestrator │ │ ┌───────────────────────────────────────────────────────┐ │ │ │ State Machine (ChatSession, Message, Context Budget) │ │ │ └───────────┬───────────────────────────────▲───────────┘ │ │ │ 2. Build Prompt & Tools │ │ │ ▼ │ │ │ ┌────────────────────────┐ ┌─────────────┴───────────┐ │ │ │ Anthropic API │ │ Hybrid RAG Pipeline │ │ │ │ (Claude 3.5/3.7) │ │ (Postgres + pgvector) │ │ │ └───────────┬────────────┘ └─────────────▲───────────┘ │ │ │ 3. Tool Call Requested │ 4. Vector/BM25│ │ ▼ │ Search │ │ ┌───────────────────────────────────────────┴───────────┐ │ │ │ MCP Client Manager │ │ │ └───────────┬───────────────────────────────────────────┘ │ └──────────────┼──────────────────────────────────────────────┘ │ 5. JSON-RPC (stdio / SSE) ▼ ┌─────────────────────────────────────────────────────────────┐ │ Decoupled MCP Servers │ │ ┌───────────────────────┐ ┌───────────────────────┐ │ │ │ Postgres DB Inspector│ │ GitHub / Jira Worker │ │ │ └───────────────────────┘ └───────────────────────┘ │ └─────────────────────────────────────────────────────────────┘ The system operates across three distinct execution layers:
- The Client Layer (Vue 3): Consumes a typed Server-Sent Events stream, rendering token deltas, inline tool invocations, and structured citations in real time.
- The Orchestration Layer (Laravel): Manages persistence, user authentication, tenant scoping, context pruning, RAG retrieval, and the model loop.
- The Protocol Layer (MCP Servers): Independent processes running outside the PHP request lifecycle that expose tools, resources, and prompts over standard JSON-RPC 2.0.
1. The Agent State Machine: Taming Non-Deterministic Loops
Scenario
An autonomous agent is asked: "Analyze why customer churn jumped by 14% in Q3 and pull the top five support tickets related to billing errors."
If your agent loop simply dumps model output directly to the database, a single malformed tool call, infinite recursive loop, or API timeout will corrupt the entire conversation history, leaving the user with an orphaned spinner and a wasted token budget.
Why it matters
LLMs are non-deterministic state machines. When a model decides to execute a tool, it outputs a structured payload (such as JSON) indicating the tool name and input parameters. The server must intercept this request, validate it, execute the tool, feed the result back to the model as a tool_result message, and ask the model to continue. Without an explicit, persistent state machine, you cannot pause execution for human-in-the-loop approvals, handle timeouts, or prevent infinite tool invocation loops.
Solution
We construct an explicit state machine inside Laravel using an Eloquent model (AgentRun) that tracks every step of the execution cycle: pending, thinking, tool_calling, awaiting_input, completed, and failed.
namespace App\Enums; enum AgentStatus: string { case Pending = 'pending'; case Thinking = 'thinking'; case ExecutingTool = 'executing_tool'; case AwaitingInput = 'awaiting_input'; case Streaming = 'streaming'; case Completed = 'completed'; case Failed = 'failed'; } Now, we implement the primary orchestrator that coordinates between Anthropic's Claude API, our tool registry, and database persistence:
namespace App\Services\Agent; use App\Enums\AgentStatus; use App\Models\AgentRun; use App\Models\Conversation; use App\Services\Mcp\McpClientManager; use App\Services\Rag\RagRetriever; use Generator; use Illuminate\Support\Facades\Log; class AgentOrchestrator { private const MAX_ITERATIONS = 8; public function __construct( protected McpClientManager $mcpManager, protected RagRetriever $ragRetriever, protected AnthropicClient $llmClient ) {} /** * Executes the agent loop, yielding SSE-formatted events along the way. * * @return Generator<string, mixed> */ public function run(Conversation $conversation, string $userPrompt): Generator { $run = AgentRun::create([ 'conversation_id' => $conversation->id, 'status' => AgentStatus::Pending, 'iteration_count' => 0, ]); // 1. Fetch relevant RAG context documents yield ['event' => 'status', 'data' => ['message' => 'Searching knowledge base...']]; $retrievedContext = $this->ragRetriever->retrieve($userPrompt, limit: 5); // 2. Build initial message history $conversation->messages()->create([ 'role' => 'user', 'content' => $userPrompt, ]); $availableTools = $this->mcpManager->getAllToolDefinitions(); $systemPrompt = $this->buildSystemPrompt($retrievedContext); $iteration = 0; $run->update(['status' => AgentStatus::Thinking]); while ($iteration < self::MAX_ITERATIONS) { $iteration++; $run->increment('iteration_count'); $messages = $conversation->getSanitizedMessageHistory(); $responseStream = $this->llmClient->streamConversation( system: $systemPrompt, messages: $messages, tools: $availableTools ); $currentToolCall = null; $accumulatedContent = ''; foreach ($responseStream as $chunk) { if ($chunk['type'] === 'text_delta') { $accumulatedContent .= $chunk['text']; yield ['event' => 'token', 'data' => ['delta' => $chunk['text']]]; } elseif ($chunk['type'] === 'tool_use_start') { $run->update(['status' => AgentStatus::ExecutingTool]); $currentToolCall = [ 'id' => $chunk['id'], 'name' => $chunk['name'], 'input' => '', ]; yield ['event' => 'tool_start', 'data' => ['name' => $chunk['name'], 'id' => $chunk['id']]]; } elseif ($chunk['type'] === 'tool_use_delta') { $currentToolCall['input'] .= $chunk['input_json_delta']; } elseif ($chunk['type'] === 'tool_use_stop') { yield ['event' => 'tool_executing', 'data' => ['id' => $currentToolCall['id']]]; // Parse inputs and execute via MCP $parsedArgs = json_decode($currentToolCall['input'], true) ?? []; $toolResult = $this->executeToolWithCircuitBreaker( $currentToolCall['name'], $parsedArgs ); // Persist model tool request and the tool execution result $conversation->messages()->create([ 'role' => 'assistant', 'content' => [ ['type' => 'tool_use', 'id' => $currentToolCall['id'], 'name' => $currentToolCall['name'], 'input' => $parsedArgs] ] ]); $conversation->messages()->create([ 'role' => 'user', 'content' => [ ['type' => 'tool_result', 'tool_use_id' => $currentToolCall['id'], 'content' => json_encode($toolResult)] ] ]); yield ['event' => 'tool_complete', 'data' => [ 'id' => $currentToolCall['id'], 'result' => $toolResult ]]; // Loop back to give the tool result to the LLM continue 2; } } // If we reached here without a tool call, the model produced its final response if (!empty($accumulatedContent)) { $conversation->messages()->create([ 'role' => 'assistant', 'content' => $accumulatedContent, ]); } $run->update(['status' => AgentStatus::Completed]); yield ['event' => 'done', 'data' => ['run_id' => $run->id]]; return; } $run->update(['status' => AgentStatus::Failed, 'error' => 'Max iterations exceeded']); yield ['event' => 'error', 'data' => ['message' => 'Agent execution reached cycle limit without resolving.']]; } private function executeToolWithCircuitBreaker(string $toolName, array $arguments): array { try { return $this->mcpManager->executeTool($toolName, $arguments); } catch (\Throwable $e) { Log::error("Tool execution failed: {$toolName}", ['error' => $e->getMessage()]); return [ 'is_error' => true, 'content' => "Error executing tool '{$toolName}': " . $e->getMessage(), ]; } } private function buildSystemPrompt(array $contextDocs): string { $contextSection = collect($contextDocs)->map(function ($doc, $idx) { return "<document index=\"{$idx}\">\n<title>{$doc['title']}</title>\n<content>{$doc['content']}</content>\n</document>"; })->implode("\n\n"); return <<<PROMPT You are an advanced enterprise intelligence agent with direct access to internal databases, APIs, and analytical tools. Always prioritize using verified context documents before making assumptions. CRITICAL INSTRUCTIONS: 1. Always ground your answers in the provided knowledge base context when possible. 2. If you need data you do not have, check your available tools and execute them. 3. If a tool returns an error, evaluate whether you can retry with altered parameters or explain the limitation to the user. 4. Output your reasoning concisely before making tool calls. CONTEXT DOCUMENTS: {$contextSection} PROMPT; } } Why this works
Using a PHP Generator allows Laravel to maintain a persistent loop without consuming massive RAM. Each step of the model's output—whether a text token, a tool start signal, or a completed database query—is yielded instantly to the transport layer. If the client disconnects, Laravel detects the broken socket and halts execution cleanly, preventing orphaned LLM API charges.
🧠 The important part:
Notice thecontinue 2;statement when a tool completes. This explicitly restarts the outerwhileloop, packing the newtool_resultmessage into the conversation array and querying Claude again so it can synthesize the data it just fetched.
2. Building an MCP Client in PHP: Standardizing Tool Execution
Scenario
Your agent needs to query a PostgreSQL analytics replica, read issues from Jira, and inspect files in an Amazon S3 bucket.
The standard solution is to write three custom Laravel service classes, manually format their schemas for OpenAI/Anthropic tool specifications, and write custom validation layers for each. When your team adds a Python service next month, you must rewrite those tools from scratch.
Why it matters
The Model Context Protocol (MCP), open-sourced by Anthropic, solves the N×M tool integration nightmare. MCP defines a standardized JSON-RPC 2.0 protocol over stdio (standard input/output) or SSE (HTTP) transports. An MCP server advertises its available capabilities, tools, and resource templates via a unified contract.
By building an MCP client directly inside Laravel, our PHP backend can seamlessly connect to any MCP server—whether written in TypeScript, Python, Go, or Rust—without writing custom wrappers for each tool.
┌─────────────────────────────────────────────────────────────┐ │ Laravel Host Process │ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ McpClientManager │ │ │ └──────────┬───────────────────────────────▲──────────┘ │ │ │ JSON-RPC 2.0 Request │ JSON-RPC │ │ │ (tools/call) │ Response │ │ ▼ │ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ ProcessTransport (proc_open) │ │ │ └──────────┬───────────────────────────────▲──────────┘ │ └──────────────┼───────────────────────────────┼──────────────┘ │ STDIN │ STDOUT ▼ │ ┌─────────────────────────────────────────────────────────────┐ │ External MCP Server (Node/Python) │ │ e.g., @modelcontextprotocol/server-postgres │ └─────────────────────────────────────────────────────────────┘ Solution
Let's build a clean, typed PHP implementation of an MCP Client that communicates with local servers over standard input/output pipes (stdio).
namespace App\Services\Mcp; use RuntimeException; use Symfony\Component\Process\Process; class McpProcessClient { /** @var resource|null */ private $process = null; /** @var array<int, resource> */ private array $pipes = []; private int $requestId = 0; public function __construct( protected string $command, protected array $environment = [] ) {} public function start(): void { $descriptorSpec = [ 0 => ['pipe', 'r'], // STDIN 1 => ['pipe', 'w'], // STDOUT 2 => ['pipe', 'w'], // STDERR ]; $this->process = proc_open( $this->command, $descriptorSpec, $this->pipes, base_path(), array_merge($_ENV, $this->environment) ); if (!is_resource($this->process)) { throw new RuntimeException("Failed to spawn MCP Server process: {$this->command}"); } // Initialize MCP connection handshake $this->sendNotification('notifications/initialized'); } /** * Lists all tools advertised by this MCP server. */ public function listTools(): array { $response = $this->sendRequest('tools/list', []); // Map the MCP tool format to Anthropic's expected tool format return array_map(function ($tool) { return [ 'name' => $tool['name'], 'description' => $tool['description'] ?? '', 'input_schema' => $tool['inputSchema'], ]; }, $response['result']['tools'] ?? []); } /** * Executes a specific tool by name with arguments. */ public function callTool(string $name, array $arguments): array { $response = $this->sendRequest('tools/call', [ 'name' => $name, 'arguments' => $arguments, ]); if (isset($response['error'])) { throw new RuntimeException("MCP Error [{$response['error']['code']}]: {$response['error']['message']}"); } return $response['result'] ?? []; } private function sendRequest(string $method, array $params): array { $this->requestId++; $payload = [ 'jsonrpc' => '2.0', 'id' => $this->requestId, 'method' => $method, 'params' => (object) $params, ]; $json = json_encode($payload) . "\n"; fwrite($this->pipes[0], $json); fflush($this->pipes[0]); $rawResponse = fgets($this->pipes[1]); if ($rawResponse === false) { $err = stream_get_contents($this->pipes[2]); throw new RuntimeException("MCP Server closed pipe unexpectedly: {$err}"); } return json_decode($rawResponse, true) ?? []; } private function sendNotification(string $method, array $params = []): void { $payload = [ 'jsonrpc' => '2.0', 'method' => $method, 'params' => (object) $params, ]; fwrite($this->pipes[0], json_encode($payload) . "\n"); fflush($this->pipes[0]); } public function __destruct() { if (is_resource($this->process)) { foreach ($this->pipes as $pipe) { if (is_resource($pipe)) { fclose($pipe); } } proc_close($this->process); } } } Now, we create the McpClientManager to aggregate multiple servers configured in config/mcp.php:
namespace App\Services\Mcp; class McpClientManager { /** @var array<string, McpProcessClient> */ protected array $clients = []; public function __construct() { $this->bootServers(); } protected function bootServers(): void { $servers = config('mcp.servers', []); foreach ($servers as $name => $cfg) { $client = new McpProcessClient($cfg['command'], $cfg['env'] ?? []); $client->start(); $this->clients[$name] = $client; } } public function getAllToolDefinitions(): array { $allTools = []; foreach ($this->clients as $serverName => $client) { foreach ($client->listTools() as $tool) { // Namespace tool to prevent collisions: e.g. postgres__querySource:https://dev.to/hosseinhezami/i-built-a-full-stack-ai-agent-with-laravel-vue-mcp-and-rag-heres-how-it-works-538o