# Self-Hosted MCP: Building a Model Context Protocol Server in PHP

> Source: <https://dev.to/mahmut_gndzalp_c736ac4b/self-hosted-mcp-building-a-model-context-protocol-server-in-php-36a2>
> Published: 2026-07-23 11:11:26+00:00

Most of the Model Context Protocol tutorials you'll find are written in TypeScript or Python, because those are the languages with official SDKs. That leaves a fair number of us — people maintaining PHP applications that hold years of business data — wondering whether the protocol is even available to us.

It is. MCP is a wire protocol, not a library. If your language can read a line from standard input and write JSON back, you can implement a server in it. This post walks through what the protocol actually asks of you, what a PHP implementation looks like in outline, and — the part I underestimated — what changes when you expose an internal system to a model.

The Model Context Protocol is an open standard for connecting AI assistants to external systems: your data, your tools, your APIs. The problem it solves is combinatorial. Before a standard existed, every assistant needed a bespoke integration with every data source. MCP defines one interface so that any compliant client can talk to any compliant server.

Underneath, it's **JSON-RPC 2.0**. Requests carry a `jsonrpc`

version, a `method`

, a `params`

object, and an `id`

; responses carry the matching `id`

and either a `result`

or an `error`

. Notifications are requests without an `id`

and expect no reply. If you've implemented a JSON-RPC service before, you already know 80% of the transport story.

A server exposes three kinds of primitive:

The distinction between tools and resources matters more than it first appears. A rough rule: **tools are model-controlled, resources are application-controlled.** If the model should decide whether to fetch something, make it a tool. If the user or the host application decides, make it a resource.

MCP defines two standard transports, and picking the right one is the first architectural decision.

**stdio.** The client launches your server as a subprocess and talks to it over standard input and output — newline-delimited JSON, one message per line. This is the simplest possible setup: no ports, no HTTP server, no authentication layer, because the only thing that can talk to your process is the parent that spawned it. It's the right choice for anything running on the same machine as the client.

Two rules with stdio, both easy to break in PHP:

`echo`

, a `var_dump`

left in from debugging, or a PHP warning printed to stdout corrupts the message stream and the client will fail to parse it. Send diagnostics to **Streamable HTTP.** The server runs as an ordinary HTTP endpoint. The client POSTs JSON-RPC messages to it; the server replies with either a single JSON response or a stream of server-sent events when it needs to push multiple messages for one request. This is the transport for a server that runs somewhere other than the user's machine — which, for most PHP shops, is the interesting case, because that's the deployment model we already know how to operate.

(An older HTTP+SSE transport exists in earlier revisions of the spec. New work should target Streamable HTTP.)

Whichever transport you pick, the conversation starts the same way. The client sends `initialize`

with the protocol version it speaks and the capabilities it supports. Your server replies with its own protocol version, its capabilities, and its name and version. The client then sends an `initialized`

notification, and normal operation begins.

Capabilities are how the two sides negotiate. If your server doesn't implement resources, you don't advertise the resources capability, and a well-behaved client won't call `resources/list`

. Don't advertise what you haven't built.

After the handshake, the methods that matter are predictable:

| Method | What it does |
|---|---|
`tools/list` |
Return the tools you expose, with descriptions and input schemas |
`tools/call` |
Execute one tool with the given arguments, return its result |
`resources/list` |
Return available resources with their URIs |
`resources/read` |
Return the contents of one resource |
`prompts/list` |
Return available prompt templates |
`prompts/get` |
Return one filled-in prompt |

A minimal but genuinely useful server is `initialize`

plus `tools/list`

plus `tools/call`

. Everything else is optional.

The structural shape, transport-independent:

```
read a JSON-RPC message
  → dispatch on `method`
  → build a result (or an error)
  → write the response with the same `id`
```

For stdio, that's a loop over `fgets(STDIN)`

, `json_decode`

, a `match`

on the method name, and `fwrite(STDOUT, json_encode($response) . "\n")`

. For Streamable HTTP, it's a single endpoint that decodes the request body and returns the encoded response. The dispatch layer in the middle is identical; only the read and write ends change. Write it that way from the start and you can support both from one codebase.

Three PHP-specific things worth knowing before you start:

**Tool schemas.** Every tool needs a JSON Schema for its inputs. Hand-writing those as nested arrays gets tedious fast. Deriving them from something you already maintain — a validation ruleset, a DTO, a set of typed constructor parameters read via reflection — keeps the schema and the actual implementation from drifting apart. Schema drift is the single most common cause of "the model keeps calling the tool wrong."

**Error handling.** Distinguish two kinds of failure. A malformed request or an unknown method is a **protocol error**: return a JSON-RPC `error`

object. A tool that ran but failed — record not found, validation rejected the input — is a **tool error**: return a normal result with the error flag set and a human-readable message. The distinction matters because the second kind goes back to the model, which can read the message and adjust. A protocol error just tells it something broke. Convert PHP exceptions into the second kind wherever the failure is something the model could plausibly recover from.

**Long-running work.** PHP's request-per-process model is a good fit for stdio (the process lives as long as the session) and a slightly awkward one for HTTP if a tool takes minutes. Keep tool calls short. If a tool kicks off something slow, return a job identifier immediately and expose a second tool that reports status. Models handle that pattern well; they handle a request that times out badly.

Once the server runs, there are three broad ways to reach it.

**Local, over stdio.** Desktop and CLI clients — Claude Desktop, Claude Code, and various IDE integrations — let you register a server by specifying a command to run and its arguments (`php`

, plus the path to your server script), and they manage the subprocess for you. This is the fastest way to get from "it responds to `tools/list`

" to "I'm using it."

**Remote, over HTTP.** A Streamable HTTP server deployed at a URL can be registered with clients that support remote connections. The Claude API also has an MCP connector: you declare the server's URL in the request, and the API makes the connection server-side, so the model can call your tools without you writing a client loop. Note that hosted MCP servers generally authenticate with OAuth bearer tokens rather than a service's own native API key — those are different auth systems, and assuming the latter works is a common early stumble.

**From your own code.** If you're building an agent rather than using an existing client, most AI SDKs can convert MCP tool definitions into their native tool format, so an MCP server becomes a source of tools for a loop you control.

Whichever route, **debug over stdio first**. The failure modes are simpler: no TLS, no auth, no proxy, no CORS. Get the protocol right, then move it to HTTP.

Here's the thing that changes when you put an MCP server in front of an existing system. Every tool you expose is a capability granted to a model that is, at least in part, steered by text it reads from the outside world. If the model can be persuaded to call your tool, your tool runs.

The practical consequences:

**Scope tools narrowly.** A generic `run_query`

tool that accepts arbitrary SQL is the most convenient thing to build and the worst thing to ship. Prefer specific tools with typed parameters — `find_customer_by_email`

, `list_orders_in_range`

— and validate every argument server-side as if it came from an anonymous HTTP request. It did, effectively.

**Read and write are different risk classes.** Read-only tools have a disclosure risk. Write tools have a *this actually happened* risk. Separate them, and put anything destructive or irreversible behind an explicit confirmation in the host application rather than trusting the model to be careful.

**The description is part of the security surface.** Tool descriptions are instructions the model reads. A vague description invites the model to call a tool in situations you didn't intend. Being prescriptive about *when* a tool should be used — not just what it does — measurably improves both correctness and safety.

**Don't leak what you didn't mean to expose.** Return the fields the task needs, not whole records. An internal note, a cost price, or a personal phone number that shouldn't be in a customer-facing answer shouldn't be in a tool result either. Filter at the server, not in the prompt.

**Log every call.** Tool name, arguments, caller, outcome. When someone asks "why did it do that," the log is the only answer you'll have.

None of this is exotic — it's the same discipline you'd apply to a public API endpoint. The difference is that the caller is a language model rather than a developer reading your docs, so ambiguity gets resolved in ways you didn't anticipate rather than surfacing as a support ticket.

For a PHP application sitting on years of accumulated business data, MCP is the cheapest bridge I've found between that data and an assistant that can actually reason about it. There's no SDK to wait for. It's JSON-RPC over a pipe or an HTTP endpoint — both things PHP has done well for twenty years.

Start with three read-only tools that answer questions someone in your organization asks weekly. Ship it over stdio to one person. See what they actually ask for. That's a far better spec than anything you'd design up front.

*If you've built an MCP server in a language without an official SDK, I'd be curious what tripped you up — the transport, the schemas, or the scoping.*
