How I Built an MCP Server in PHP for Fayyaz Travels
ByManish ShahiSoftware Engineer • AI Developer
Table of contents(22)
I was half a chai deep when another travel AI demo did the usual thing: pretty answer, zero live data, human still opening admin tabs.
That gap is what Fayyaz Travels needed closed. They are a client of Inncelerator. They did not want another chatbot that guesses. They wanted the model to pull real numbers from their PHP stack.
So I built an MCP server in PHP.
Today it only does website analytics (traffic, leads, that kind of site readout). Flights, hotels, holiday packages, and ticket email are planned on the same server later.
Most people still assume MCP means Node. I stayed in PHP.
Why I started with analytics (not flights)
It is tempting to jump straight to “check flights” or “book Dubai.” That is also how you ship a mess.
Analytics is a safer first tool: read-only, clear inputs, easy to cache. If something is wrong, you find out without touching customers or bookings.
So day one for Fayyaz Travels looked like this:
| Live now | Later (same MCP) |
|---|---|
| Website analytics tools | check_flights, check_hotels, lookup_holiday |
| “How did the site do last 30 days?” | Draft / send ticket replies (with approval) |
| PHP + Redis + MySQL | Still PHP + Redis + MySQL, just more tools |
The problem I kept hitting
For Fayyaz Travels the day-one ask was simple: “How did the site do last week? Traffic? Leads?”
The answer already lived in their PHP stack (MySQL + Redis). Getting it still meant opening analytics panels, squinting at charts, and pasting numbers into chat. An agent could write a nice summary, but it had no live feed. So it guessed, stalled, or waited for a human to copy-paste.
That is the same gap as those flashy travel demos, just less glamorous: the model talks, the system stays locked.
| Shortcut | Why it sucks |
|---|---|
| Paste dashboard screenshots into the prompt | Stale in minutes, burns context |
| Dump DB rows into chat | Too much noise, easy to leak internals |
| One custom API per metric | Glue forever for every new question |
| “Please check the admin” | The agent dies on the step that matters |
I needed a thin plug into their existing PHP world. MCP is that plug. “Check the site numbers” becomes a tool call, not a scavenger hunt.
MCP in plain words
MCP is a plug between the chat app and your real systems (here: the Fayyaz Travels PHP site).
- The chat asks: “What jobs can you do?”
- Your server answers with a short menu of tools (for now: website analytics).
- Someone asks in plain English: “How did traffic look last 7 days?”
- The chat picks the right tool, your PHP code runs, live numbers come back.
No dumping the whole database into the chat. No guessing. Just: ask → tool → answer.
- Client asks what tools exist →
tools/list - Client runs one →
tools/call - Transport:
stdiolocally, or HTTP + SSE on a network - Messages: JSON-RPC
MCP does not replace the website or booking stack. It is a socket so Claude, Cursor, or an internal chat can call PHP tools with a schema.
For this project, the live tool surface is analytics. Everything else in the table is roadmap, same server later:
| Job | Without MCP | Status |
|---|---|---|
| Site analytics (traffic, leads) | Open dashboards, paste into chat | Live |
| Flights | Tabs and copy-paste | Planned (check_flights) |
| Hotels | Supplier soup | Planned (check_hotels) |
| Holidays | “I’ll forward the PDF” | Planned (lookup_holiday) |
| Ticket doubts | Inbox ↔ CRM ping-pong | Planned (draft, then send with approval) |
Why PHP? Because that is the website
Most MCP tutorials are Node or Python. Cool. Their site is not.
Fayyaz Travels already runs:
| Layer | Stack |
|---|---|
| Server | PHP |
| Front | HTML, Bootstrap, JS |
| Cache | Redis |
| DB | MySQL |
| Speed | Custom PHP scripts that keep pages snappy |
Putting MCP in a second language meant copying MySQL access, Redis keys, and those speed scripts. I refused. MCP lives where the site lives.
What I aimed for:
- Reuse PHP + MySQL + Redis
- Keep agent calls as fast as the site’s own scripts
- Run MCP from CLI, off the public request path
- Stdio first, SSE when I needed a network face
Pieces that mattered: PHP CLI, php://stdin / php://stdout, a PHP MCP library for discovery + SSE, and the same Redis/MySQL the Bootstrap front already leans on.
How a call actually moves
sequenceDiagram participant Agent as Agent client participant MCP as PHP MCP server participant Data as Redis / MySQL Agent->>MCP: 1. tools/list MCP-->>Agent: 2. tool names + schemas Agent->>MCP: 3. tools/call MCP->>Data: 4. read cache, then MySQL if needed Data-->>MCP: 5. slim payload MCP-->>Agent: 6. JSON-RPC result (stdio / SSE) Note over Agent: 7. Model answers with real numbers
You own what is allowed. The model owns when to call. That split matters even for read-only analytics, and more once mailers or flight search land later.
Analytics path (live)
flowchart TD
A["tools/call (get_website_analytics)"] --> B["Validate range"]
B --> C{"Redis GET (analytics summary)"}
C -->|hit| D["Slim JSON"]
C -->|miss| E["MySQL aggregate"]
E --> F["Redis SETEX (short TTL)"]
F --> D
D --> G["JSON-RPC result (back to agent)"]
classDef box fill:#f7f5f0,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
classDef decision fill:#eef6f4,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
class A,B,D,E,F,G box
class C decision
Fake numbers in the samples below. Real payloads stay small on purpose: totals and trends, not event dumps.
Same pattern later for flights (not live)
flowchart TD
A["tools/call (check_flights, planned)"] --> B["Validate args"]
B --> C{"Redis GET (flight search key)"}
C -->|hit| D["Top N options"]
C -->|miss| E["Search / MySQL"]
E --> F["Redis SETEX (short TTL)"]
F --> D
D --> G["JSON-RPC result (back to agent)"]
classDef box fill:#f7f5f0,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
classDef decision fill:#eef6f4,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
class A,B,D,E,F,G box
class C decision
If you return raw rows, the model wastes tokens on junk columns. Map to a tiny DTO.
Wire samples (analytics)
Shapes only. Not production data.
{ "jsonrpc": "2.0", "id": 1, "method": "tools/list"}{ "jsonrpc": "2.0", "id": 1, "result": { "tools": [ { "name": "get_website_analytics", "description": "Read-only website analytics summary for a date range.", "inputSchema": { "type": "object", "properties": { "range": { "type": "string", "enum": ["7d", "30d", "90d"], "default": "30d" } } } } ] }}{ "jsonrpc": "2.0", "id": 2, "method": "tools/call", "params": { "name": "get_website_analytics", "arguments": { "range": "30d" } }}{ "jsonrpc": "2.0", "id": 2, "result": { "content": [ { "type": "text", "text": "{\"range\":\"30d\",\"visitors\":12450,\"sessions\":15820,\"top_pages\":[\"/\",\"/tours\",\"/flights\"],\"cached\":true}" } ] }}Keep it under a few KB. Big payloads torch the context window and the model gets worse, not better.
Schema first, then code
Vague schemas = invented arguments. Write the contract before the if.
{ "name": "get_website_analytics", "description": "Read-only website analytics summary. No PII. No raw event dumps.", "inputSchema": { "type": "object", "additionalProperties": false, "properties": { "range": { "type": "string", "enum": ["7d", "30d", "90d"], "default": "30d" } } }}{ "name": "check_flights", "description": "Read-only flight search. Does not book. Planned add-on.", "inputSchema": { "type": "object", "additionalProperties": false, "properties": { "origin": { "type": "string", "pattern": "^[A-Z]{3}$" }, "destination": { "type": "string", "pattern": "^[A-Z]{3}$" }, "date": { "type": "string", "format": "date" }, "nonstop_only": { "type": "boolean", "default": false }, "limit": { "type": "integer", "minimum": 1, "maximum": 10, "default": 5 } }, "required": ["origin", "destination", "date"] }}Hotels, holidays, draft reply: same idea. Send mail only after a human yes.
Approach 1: stdio loop
This is how I started. No port. No nginx. Just a process the agent owns.
$fp = fopen('php://stdin', 'r');
while (!feof($fp)) { $line = fgets($fp); if ($line === false) { break; }
$req = json_decode(trim($line), true); if (!is_array($req)) { continue; }
handle_request($req);}
fclose($fp);Analytics handler, cache first:
if ($name === 'get_website_analytics') { $range = (string)($args['range'] ?? '30d'); if (!in_array($range, ['7d', '30d', '90d'], true)) { $range = '30d'; }
$cacheKey = 'analytics:summary:' . $range; $cached = $redis->get($cacheKey); if ($cached !== false) { respond_tool_text($req['id'], $cached); return; }
$summary = $analyticsService->summarize($range); $payload = json_encode([ 'range' => $range, 'summary' => $summary, 'cached' => false, ]);
$redis->setex($cacheKey, 300, $payload); respond_tool_text($req['id'], $payload);}Thing that wasted an evening: PHP buffers stdout. Client hangs after tools/list. Flush every line (fflush(STDOUT)), or turn buffering off. You will do this once and never forget.
Approach 2: SSE when stdio gets small
I moved to SSE when I wanted a LAN endpoint and tools as files, not a giant switch.
use PhpMcp\Server\Server;use PhpMcp\Server\Transports\HttpServerTransport;
$server = Server::make() ->withServerInfo('Fayyaz Travels MCP', '1.0.0') ->build();
$server->discover( basePath: APP_BASE_DIR, scanDirs: ['tools']);
$transport = new HttpServerTransport('127.0.0.1', 8765);$server->listen($transport);Bind to localhost. Proxy only what you need. Do not put this on the public Bootstrap site with no auth.
Proxies often drop idle SSE connections. Heartbeats fix most of that.
Hook it to Cursor / Claude
{ "mcpServers": { "travel-ops": { "command": "php", "args": ["/absolute/path/to/server/stdio_server.php"] } }}{ "mcpServers": { "travel-ops": { "url": "http://127.0.0.1:8765/mcp" } }}claude mcp add --transport stdio travel-ops -- php /absolute/path/to/server/stdio_server.phpList your MCP tools.Call get_website_analytics for the last 30 days.Summarize visitors and top pages. Do not invent numbers.When later tools ship (flights and friends), swap the tool name. The habit stays.
Here is that flow in Claude against the Fayyaz Travels MCP. Red boxes mask real counts and internals so this post does not leak business data.
pong, then a natural-language analytics ask. Values masked to hide internal business data.
What almost went wrong
I almost did all of these, so I am writing them down:
- Ship
run_any_sql“just for managers.” Feels powerful. Becomes a liability. - Return full analytics rows. Model drowns. You leak noise.
- Infinite Redis TTL. Cached “live” numbers that are three days dead.
- Node sidecar “for AI only.” Two codebases fighting over one MySQL.
- Public MCP URL on day one. No. Localhost until auth is real.
Rules I kept: one verb per tool, small outputs, read before write, approvals for side effects, no secrets in results.
| Tool | Status | Notes |
|---|---|---|
get_website_analytics |
Live | Read-only summary |
check_flights |
Planned | No book-by-default |
check_hotels |
Planned | Cap results |
lookup_holiday |
Planned | Public fields only |
draft_ticket_reply |
Planned | Draft first |
send_ticket_reply |
Planned | Human approval + audit |
Future add-ons (examples, not live)
Analytics only today. The diagram below is where desk tools go once I add them.
Flights (planned)
Check morning flights DEL → DXB next Friday.Prefer nonstop. Top 3 by price and duration. Do not book.Hotels (planned)
4-star near Bur Dubai, 2 adults, 3 nights from 12 Aug.Board basis + cancellation if you have it.Holidays (planned)
Kerala packages for a 6-day family trip in December.Inclusions from live package data only.Fetched beats RAG-only when packages change weekly.
Ticket / questionnaire email (planned)
Ticket asks if a marriage certificate is needed for the Dubai package.Draft from FAQ + questionnaire answers. Ask before send.Flow I want:
flowchart TD
A["get_ticket_context"] --> B["draft_ticket_reply"]
B --> C{"Human approves?"}
C -->|yes| D["send_ticket_reply"]
C -->|no| E["Edit draft or stop"]
D --> F["Audit log"]
classDef box fill:#f7f5f0,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
classDef decision fill:#eef6f4,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px
class A,B,D,E,F box
class C decision
Architecture in one sketch
flowchart TB A["Agent (Cursor / Claude / chat)"] B["PHP MCP server"] B1["Analytics tools (live)"] B2["Desk tools (planned)"] D["Redis + MySQL"] E["HTML / Bootstrap / JS website"] F["Custom PHP speed scripts"] A <-->|"stdio / SSE (request + response)"| B B --- B1 B --- B2 B1 <-->|"get / set"| D B2 <-.->|"later"| D E <--> D F <-->|"warm cache / speed path"| D classDef box fill:#f7f5f0,stroke:#2a2a2a,color:#2a2a2a,stroke-width:1px class A,B,B1,B2,D,E,F box
I am not replacing their website or booking stack. I am giving the agent a way to read live site analytics from the same PHP table first. Desk tools can plug into that same MCP later.
Checklist I still use
Ship analytics
- Stdio loop + flush
-
initialize/tools/list/tools/call - One analytics tool with a real schema
-
.cursor/mcp.jsonand a real call
Harden
- Redis where the site already caches
- Cap payloads; validate args
- Localhost bind; deliberate proxy
- No secrets, no raw dumps
Next tools
-
check_flights -
check_hotels -
lookup_holiday -
draft_ticket_reply→send_ticket_reply
Closing
Fayyaz Travels did not need a prettier chatbot.
They needed last week’s traffic and leads from live data, without another round of dashboard copy-paste. Flights, hotels, packages, and careful email can come later on the same MCP, without rewriting the house in Node.
MCP is just plumbing. PHP was already there. Analytics proved the integration works.
If your stack looks like theirs, skip a second runtime. Ship one read-only tool, flush stdout, then grow.
- Client: fayyaztravels.com
- Built with: Inncelerator
- MCP · AI agent · modelcontextprotocol.io
Pinch or double-tap to zoom · tap outside to close
FAQ
Frequently asked questions
What does the Fayyaz Travels MCP do today?
Website analytics tools only. Ask an agent for live site metrics instead of opening dashboards. Flights, hotels, holidays, and ticket email are planned on the same server.
What is MCP?
Model Context Protocol is a standard so AI clients can discover and call your tools over stdio or HTTP/SSE. You expose tools. The agent picks when to use them.
Why build MCP in PHP for Fayyaz Travels?
Their site already runs PHP, HTML, Bootstrap, JS, Redis, MySQL, plus custom PHP scripts for speed. I kept MCP in that stack so tools reuse the same logic.
Stdio vs SSE: which should I start with?
Stdio for a local proof. SSE/HTTP when you need a network endpoint or attribute-based tool discovery.
What is next on the roadmap?
Flight search, hotel availability, holiday packages, and email replies for questionnaire or ticket doubts. Each one is a scoped tool, with approvals on anything that sends or spends.
What should you never expose through MCP?
Blind write access, secrets, payment credentials, or mailers without a human yes. Start read-only.
Research this topic further
- Click a tool below (ChatGPT, Perplexity, Claude, or Gemini).
- We copy the full article + companion guide prompt to your clipboard.
- A new chat tab opens — if the message box is empty or only shows a short note, press Ctrl+V (Windows/Linux) or Cmd+V (Mac) to paste.
- Send the message. The model already has the article text — it does not need to open this website.
Related posts
Comments
Share a thought on this post — keep it useful and kind. Comments are moderated before they appear.
Loading comments…