Transport: Streamable HTTP (stateless subset)
The MCP endpoint speaks the Streamable HTTP transport, in the subset a synchronous PHP front controller can serve honestly: one request in, one answer out.
Spec revisions this page was written against: 2025-06-18 (transport and lifecycle) and 2025-03-26 (the revision that still allowed batching). 2026-07-28 is the current revision at the time of writing and is not advertised: it replaces the initialize handshake with a per-request protocol version and a mandatory server/discover, which this server does not implement.
What the endpoint does
| Request | Answer |
|---|---|
POST with a JSON-RPC request |
application/json with the JSON-RPC response, or one SSE event (see Accept) |
POST with only notifications or responses |
202 Accepted, empty body |
GET |
405 Method Not Allowed + Allow: POST |
DELETE |
405 Method Not Allowed + Allow: POST |
OPTIONS |
204 CORS preflight, answered from the origin allow list before authentication runs |
| anything else | 405 Method Not Allowed + Allow: POST |
What it deliberately does not do
- No session. No
Mcp-Session-Idheader is ever issued, which the specification permits. A client that receives no session id does not send one back, skips theDELETE, and keeps working over POST alone. - No server-initiated stream.
GETreturns 405, which the specification names as the answer for a server that offers no SSE stream at the endpoint. A client that reads the 405 stops trying to listen. - No resumability. There is nothing to resume, so
Last-Event-IDis meaningless here. - No legacy HTTP+SSE transport (protocol revision
2024-11-05, with aGETthat opens a stream and returns anendpointevent). That revision is not on the supported list, so it is never negotiated.
Sessions, a server stream and resumable delivery belong to a server that can hold a connection open. On this platform that is the Festi Async daemon, not this plugin.
Protocol version
Two different rules, and the specification treats them differently.
initialize negotiates. The client sends the revision it wants. The server answers with the same revision when mcp_protocol_versions lists it, and otherwise with the first entry on that list (the server's latest). This is the lifecycle rule: "If the server supports the requested protocol version, it MUST respond with the same version. Otherwise, the server MUST respond with another protocol version it supports."
MCP-Protocol-Version is validated. Every later request carries the header. A value that is not on the supported list is answered 400 Bad Request with a JSON-RPC error naming the supported revisions. A request that carries no header is served under 2025-03-26, which the transport section names as the assumed default, whatever mcp_protocol_versions says. The supported list decides what initialize negotiates; it has no say over how a request that named no revision is framed, or an install whose list happens to hold only 2025-06-18 would start refusing batched bodies its clients have always sent.
Batching
A POST body may be a JSON array batching several JSON-RPC messages under 2025-03-26 and earlier. 2025-06-18 removed it: "The body of the POST request MUST be a single JSON-RPC request, notification, or response."
So the decision is per revision, and it is taken from the revision the request is served under:
| Revision the request is served under | Array body |
|---|---|
no MCP-Protocol-Version header (assumed 2025-03-26) |
accepted |
2025-03-26 |
accepted |
2025-06-18 and newer |
400 Bad Request |
A caller that batches today sends no version header, so it keeps working.
Accept
A client is supposed to accept both application/json and text/event-stream, and such a client is served JSON, exactly as before.
The one case that changes the shape is a client that accepts text/event-stream and nothing that covers JSON (no application/json, no application/*, no */*). It would reject a JSON body, so the same JSON-RPC response is sent as a single SSE event and the response then ends:
Content-Type: text/event-stream
Cache-Control: no-cache
X-Accel-Buffering: no
event: message
data: {"jsonrpc":"2.0","id":1,"result":{...}}
Cache-Control: no-cache and X-Accel-Buffering: no are there because a proxy that buffers an event stream holds the answer back until the connection closes, and a cache that stores it replays one client's answer to another.
A media type the caller marked q=0 is not acceptable, so it is dropped before this decision: Accept: application/json;q=0, text/event-stream gets the event stream, and Accept: text/event-stream;q=0 gets JSON.
A client that sent no Accept header at all is served JSON. A header naming neither type is served JSON too, rather than refused with 406: refusing would break callers that work today for no protocol gain.
A notification-only POST stays 202 with an empty body for every client, event stream or not.
Origin validation (DNS rebinding protection)
The specification requires the server to validate Origin, because a page on any other origin can otherwise POST to the endpoint and have the browser attach the user's session cookie.
- A request with no
Originheader always passes. A server-to-server client sends none. - A request with an
Originpasses whenmcp_allowed_originsis empty, when the origin is on that list, or when it equals the origin the server itself answers on. Otherwise:403 Forbidden. - The origin the server answers on is
https://<mcp_host>, from configuration only.mcp_hostis a bare hostname with an optional port (mcp.example.com,mcp.example.com:8443). A value with a scheme, a path, a quote or a space is refused: the plugin writes one line in therpc_errorslog and works as ifmcp_hostis empty, so there is no same-origin exemption and no metadata URL in the challenge. It is never derived from the request'sHostheader: in a DNS rebinding attack the attacker owns the hostname the victim's browser resolved, soHostandOriginagree by construction and a same-origin check taken fromHostpasses exactly when it matters most. Withmcp_hostunset there is no same-origin exemption and only the allow list decides. - An origin is compared as a whole
scheme://host[:port]string, lowercased and without a trailing slash. No wildcards and no suffix matching, sohttps://app.example.com.evil.netnever passes a list naminghttps://app.example.com.
Why the list ships empty, and what that costs
With the shipped default this endpoint performs none of the Origin validation the MCP specification makes a MUST. An empty mcp_allowed_origins refuses no origin, and the only barrier left is authentication by an explicit token: Authorization: Bearer, X-Authorization or ?token=. A rebinding or CSRF attacker cannot supply one, so a stock deployment is not open, but it is protected by a different control than the one the specification asks for.
That barrier disappears the moment a host authenticates from an ambient credential. install/mcp/common.php starts a PHP session, and a project listener on MCPBearerTokenLoginEvent or IRpc::EVENT_ON_TOKEN_LOGIN may sign a caller in from that session rather than from a token. A host whose listener does that MUST configure mcp_allowed_origins, or a page on any origin can make authenticated calls with the victim's cookie.
The list nevertheless ships empty because this plugin has never emitted a CORS header of its own and the Rpc plugin's entry-point template ships an .htaccess with Access-Control-Allow-Origin: *, so a browser consumer may exist today at the web-server level and must not be cut off silently by a plugin upgrade with a 403 that reads like an auth failure.
A deployment that pinned mcp_host (and therefore advertises itself to OAuth clients) while leaving the list empty gets a warning in the rpc_errors log for each request that carries an Origin header, which is a browser that reached the endpoint with no list to check it against. A request with no Origin (a server-to-server client) writes nothing, so a token-only deployment does not fill the log.
Set the list on any deployment reachable from a browser. Leave it empty only for an endpoint that only server-to-server clients reach and that authenticates by explicit token only.
What goes back on the answer
The Origin header is never reflected. When the caller's origin matches a list entry, the answer carries the operator's own spelling of that entry:
Access-Control-Allow-Origin: https://app.example.com
Access-Control-Allow-Credentials: true
Vary: Origin
Access-Control-Allow-Credentials is only ever sent alongside an allowed origin, so a browser is never told it may attach a session to a cross-origin call the server did not agree to.
A refusal other than the Origin 403 (400 for a protocol version or a batch, 405, 413) carries the same CORS headers, so a browser page of an allowed origin can read the JSON-RPC error. The 403 stays without them.
Vary: Origin goes on every answer whose outcome depended on Origin, which is every answer once the list is configured: the served one that carries CORS headers, the served one that does not, and the 403. A shared cache that saw only the first would otherwise replay it to an origin that should have been refused. When no list is configured nothing depends on Origin and no header is sent at all, so whatever the web server does is unchanged.
CORS preflight
The plugin answers the preflight, so the allow list is the single source of truth: a browser is told it may call this endpoint exactly when the server would serve it, with nothing to keep in sync at the web server.
MCPServerPlugin::sendCorsPreflight() is called by the entry point after the plugin is built and before bindRequest(), because a preflight carries no credentials by definition and would otherwise be refused by sign-in with a 401 that a browser reads as "no CORS at all". An allowed origin gets 204 plus Access-Control-Allow-Origin, Access-Control-Allow-Credentials, Vary: Origin, Access-Control-Allow-Methods: POST, OPTIONS, Access-Control-Allow-Headers and Access-Control-Max-Age: 600. Any other origin gets a bare 204 with no CORS header, which the browser reads as a refusal.
The allow list is a setting. The plugin loads the settings only in bindRequest(), so the entry point calls loadSettings() before sendCorsPreflight(). Without it, each preflight gets 500 and Undefined setting with key: mcp_allowed_origins. install/mcp/mcp.php does this, and McpEntryPointTemplateTest runs that template in a process of its own to prove it.
An OPTIONS that reaches the JSON-RPC handler anyway is a misrouted request and is answered 405 like any other method this endpoint does not serve.
location = /mcp {
fastcgi_pass php:9000;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME /app/src/mcp/mcp.php;
fastcgi_param REQUEST_URI /;
# An event-stream answer must reach the client as it is written.
fastcgi_buffering off;
gzip off;
}
Request size
mcp_max_request_size caps the request body in bytes and ships at 1 MiB (1048576); 0 disables the cap. A body over the cap is answered 413 with a JSON-RPC error naming the limit, before the body is parsed.
An inbound MCP body is a single JSON-RPC message, in practice a tools/call with JSON arguments, so 1 MiB is generous by an order of magnitude and no client that works today sends more.
The declared Content-Length is checked first, so a body that declares an oversized length is refused without being read at all. A Content-Length that is absent, negative or not a number declares nothing believable, so the body is instead measured while it is read, in 8 KiB chunks, and abandoned the moment it passes the limit: an oversized body is never held in memory whichever way it arrives.
PHP's own post_max_size still applies and is usually lower; this setting pins a smaller, explicit limit on the MCP endpoint alone.
Authentication failures
Every authentication failure is 401 with a WWW-Authenticate header, on every path into the endpoint. MCPServerPlugin::onException() is what produces it, so a host entry point must route both of its failure paths through that method:
$options = [
'onBind' => function () use ($systemPlugin) {
$systemPlugin->signInByRpcByToken();
},
'area' => 'mcp',
'onException' => function ($event) use ($systemPlugin) {
$systemPlugin->onException($event);
},
];
try {
$isFoundUrl = $systemPlugin->bindRequest($options);
// ...
} catch (Throwable $exp) {
// The onBind callback runs before the request is bound, so its failure
// arrives here rather than at the onException option above.
$systemPlugin->onException(['exp' => $exp]);
}
An entry point that has the onException option but no outer catch answers 500 for a missing token, because signInByRpcByToken() raises before the URL is bound. install/mcp/mcp.php has both; copy it rather than writing your own.
The challenge itself is WWW-Authenticate: Bearer, and, when mcp_host is set, WWW-Authenticate: Bearer resource_metadata="https://<mcp_host>/.well-known/oauth-protected-resource", which is what makes an OAuth-capable client begin discovery.
Settings
| Setting | Default | Meaning |
|---|---|---|
mcp_protocol_versions |
2025-06-18,2025-03-26 |
Revisions this server speaks, newest first. The first entry is what an initialize that asked for something else is answered with. Empty falls back to mcp_protocol_version. |
mcp_allowed_origins |
empty | Browser origins allowed to call the endpoint, separated by commas. Empty means no Origin is refused. |
mcp_max_request_size |
1048576 |
Largest request body in bytes. 0 disables the cap. |
mcp_protocol_version |
2025-06-18 |
The single revision an install configured before the list existed. Still read as the fallback for what initialize negotiates. It never decides how a header-less POST is framed: that is always the assumed 2025-03-26. |
mcp_host |
empty | Bare hostname, with an optional port, that this server answers on. A value with a scheme or a path is refused and logged. Pins the same-origin exemption and the OAuth metadata URL. The row must exist: without it each MCP request gets 500. install.*.sql writes it, and update2.*.sql adds it to an existing install. |