자체 호스팅 MCP: PHP에서 모델 컨텍스트 프로토콜 서버 구축

작성자

카테고리:

← 피드로
DEV Community · Mahmut Gündüzalp · 2026-07-23 개발(SW)

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.

What MCP actually is

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:

  • Tools — actions the model can invoke. Each has a name, a description, and a JSON Schema describing its inputs. This is the primitive that does something: query a database, create a record, send a request. The model chooses when to call them.
  • Resources — data the model can read, addressed by URI. Files, records, generated documents. These are for context, not action, and the client generally decides what to pull in.
  • Prompts — reusable prompt templates the user can invoke deliberately, often surfaced in the client’s UI as a slash command or menu item.

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.

The two transports

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:

  1. Never write anything but protocol messages to stdout. A stray 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 stderr instead, which the client typically forwards to a log.
  2. Turn off output buffering and flush after every write, or your responses sit in a buffer while the client waits.

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.)

The handshake

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.

What the PHP side looks like

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`

Enter fullscreen mode Exit fullscreen mode

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.

Connecting it to Claude

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.

The part I underestimated: exposure

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.

Worth it?

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.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다