* [Client][Docs][Schema][Server] Implement protocol version negotiation The `initialize` handler never looked at the version the client asked for: it always answered with the server's configured version (or the SDK default), so a client offering a revision the server supports could still be told to speak a different one. The spec requires echoing back a supported request and only counter-offering when the requested version cannot be honoured. Turns ProtocolVersion into the registry the other SDKs converged on (typescript-sdk's protocolEras.ts, python-sdk's mcp_types/version.py): `latestHandshake()`, `handshakeVersions()`, `modernVersions()` and ordered comparison through `isAtLeast()`. Comparison goes through declaration order rather than string collation, so an unrecognized peer string compares conservatively instead of accidentally sorting above a known revision. The one throw the enum can produce is the SDK's own LogicException, so a consumer catching `Mcp\Exception\ExceptionInterface` does not miss it. Adds 2026-07-28 as a known revision and splits the set into two eras: handshake (2025-11-25 and earlier, negotiated via `initialize`) and modern (2026-07-28+, per-request `_meta`). The lists are kept apart so a modern revision can never leak into an `initialize` exchange, which has no `initialize` at all. Serving the modern era is follow-up work; this only teaches the SDK that the revision exists and must not be mis-negotiated. Deliberately absent is any "newest revision in any era" accessor, which would hand back the one revision the handshake must never offer. Server: negotiates and stores the result on the session. A version pinned in Configuration still pins the handshake to exactly that revision. Client: validates the counter-offer and fails the handshake instead of continuing on a revision it cannot speak; records the negotiated version on the client state, readable through the new Client::getProtocolVersion(). The HTTP middleware now defaults to the handshake versions rather than every declared case, so it stops accepting a modern version the server cannot yet serve. When the header is absent it falls back to ProtocolVersion::DEFAULT_NEGOTIATED_VERSION (2025-03-26), the revision that introduced both Streamable HTTP and the header itself. The negotiation behaviour is one provideNegotiationTable() data provider whose rows are the documented table -- supported revision echoed back, unknown or malformed counter-offered, modern counter-offered -- so the docs and the suite cannot drift apart. The supported-revision rows iterate handshakeVersions() rather than a literal list, so declaring a new handshake revision extends the table automatically, and the "never answer with a modern revision" assertion runs on every row. Documented in server-builder.md (the enum registry, how the server answers a handshake, what pinning changes), transports.md (the header check) and client.md (the client's side), all linking the spec's versioning section. * [Client][Docs][Schema][Server] Warn when the configured protocol version cannot be negotiated * Log a warning instead of silently falling back when a modern revision is configured on the client or pinned on the server builder. * Document that setProtocolVersion() does not restrict the MCP-Protocol-Version header check, which validates against a fixed set. * Pin every revision's era in a test, so a new case declared past the modern boundary fails instead of dropping out of the handshake set. * Add CHANGELOG entry. * [Client][Docs][Schema][Server] Rename version fallback constant and trim comments
548 lines
18 KiB
Markdown
548 lines
18 KiB
Markdown
# Transports
|
|
|
|
Transports handle the communication layer between MCP servers and clients. The PHP MCP SDK provides two main transport
|
|
implementations: STDIO for command-line integration and HTTP for web-based communication.
|
|
|
|
## Table of Contents
|
|
|
|
- [Transport Overview](#transport-overview)
|
|
- [STDIO Transport](#stdio-transport)
|
|
- [HTTP Transport](#http-transport)
|
|
- [Choosing a Transport](#choosing-a-transport)
|
|
|
|
## Transport Overview
|
|
|
|
All transports implement the `TransportInterface` and follow the same basic pattern:
|
|
|
|
```php
|
|
$server = Server::builder()
|
|
->setServerInfo('My Server', '1.0.0')
|
|
->setDiscovery(__DIR__, ['.'])
|
|
->build();
|
|
|
|
$transport = new SomeTransport();
|
|
|
|
$result = $server->run($transport); // Blocks for STDIO, returns a response for HTTP
|
|
```
|
|
|
|
## STDIO Transport
|
|
|
|
The STDIO transport communicates via standard input/output streams, ideal for command-line tools and MCP client integrations.
|
|
|
|
```php
|
|
$transport = new StdioTransport(
|
|
input: STDIN, // Input stream (default: STDIN)
|
|
output: STDOUT, // Output stream (default: STDOUT)
|
|
logger: $logger // Optional PSR-3 logger
|
|
);
|
|
```
|
|
|
|
### Parameters
|
|
|
|
- **`input`** (optional): Input stream resource. Defaults to `STDIN`.
|
|
- **`output`** (optional): Output stream resource. Defaults to `STDOUT`.
|
|
- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`.
|
|
|
|
> [!IMPORTANT]
|
|
> When using STDIO transport, **never** write to `STDOUT` in your handlers as it's reserved for JSON-RPC communication.
|
|
> Use `STDERR` for debugging instead.
|
|
|
|
### Example Server Script
|
|
|
|
```php
|
|
#!/usr/bin/env php
|
|
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
require_once __DIR__ . '/vendor/autoload.php';
|
|
|
|
use Mcp\Server;
|
|
use Mcp\Server\Transport\StdioTransport;
|
|
|
|
$server = Server::builder()
|
|
->setServerInfo('STDIO Calculator', '1.0.0')
|
|
->addTool(function(int $a, int $b): int { return $a + $b; }, 'add_numbers')
|
|
->addTool(InvokableCalculator::class)
|
|
->build();
|
|
|
|
$transport = new StdioTransport();
|
|
|
|
$status = $server->run($transport);
|
|
|
|
exit($status); // 0 on clean shutdown, non-zero if STDIN errored
|
|
```
|
|
|
|
### Client Configuration
|
|
|
|
For MCP clients like Claude Desktop:
|
|
|
|
```json
|
|
{
|
|
"mcpServers": {
|
|
"my-php-server": {
|
|
"command": "php",
|
|
"args": ["/absolute/path/to/server.php"]
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## HTTP Transport
|
|
|
|
The HTTP transport was designed to sit between any PHP project, regardless of the HTTP implementation or how they receive
|
|
and process requests and send responses. It provides a flexible architecture that can integrate with any PSR-7 compatible application.
|
|
|
|
```php
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
|
|
// PSR-17 factories are automatically discovered
|
|
$transport = new StreamableHttpTransport(
|
|
request: $serverRequest, // PSR-7 server request
|
|
responseFactory: null, // Optional: PSR-17 response factory (auto-discovered if null)
|
|
streamFactory: null, // Optional: PSR-17 stream factory (auto-discovered if null)
|
|
logger: $logger // Optional PSR-3 logger
|
|
);
|
|
```
|
|
|
|
### Parameters
|
|
|
|
- **`request`** (required): `ServerRequestInterface` - The incoming PSR-7 HTTP request
|
|
- **`responseFactory`** (optional): `ResponseFactoryInterface` - PSR-17 factory for creating HTTP responses. Auto-discovered if not provided.
|
|
- **`streamFactory`** (optional): `StreamFactoryInterface` - PSR-17 factory for creating response body streams. Auto-discovered if not provided.
|
|
- **`logger`** (optional): `LoggerInterface` - PSR-3 logger for debugging. Defaults to `NullLogger`.
|
|
- **`middleware`** (optional): `iterable<MiddlewareInterface>|null` - PSR-15 middleware chain. `null` (omitted) installs the [default stack](#default-middleware). `[]` disables all defaults — useful when the surrounding application already handles CORS, host validation, etc.
|
|
- **`maxBodyBytes`** (optional): `int` - Upper bound on the POST request body read, in bytes. Defaults to 4 MiB (`StreamableHttpTransport::DEFAULT_MAX_BODY_BYTES`). See [Request Body Size Limit](#request-body-size-limit).
|
|
|
|
### PSR-17 Auto-Discovery
|
|
|
|
The transport automatically discovers PSR-17 factory implementations from these popular packages:
|
|
|
|
- `nyholm/psr7`
|
|
- `guzzlehttp/psr7`
|
|
- `slim/psr7`
|
|
- `laminas/laminas-diactoros`
|
|
- And other PSR-17 compatible implementations
|
|
|
|
```bash
|
|
# Install any PSR-17 package - discovery works automatically
|
|
composer require nyholm/psr7
|
|
```
|
|
|
|
If auto-discovery fails or you want to use a specific implementation, you can pass factories explicitly:
|
|
|
|
```php
|
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
|
|
|
$psr17Factory = new Psr17Factory();
|
|
$transport = new StreamableHttpTransport($request, $psr17Factory, $psr17Factory);
|
|
```
|
|
|
|
### Default Middleware
|
|
|
|
When the `middleware` argument is omitted (or set to `null`), the transport installs a secure default stack:
|
|
|
|
| Order | Middleware | Purpose |
|
|
|-------|------------|---------|
|
|
| 1 | `CorsMiddleware` | Applies CORS headers to every response. By default does **not** set `Access-Control-Allow-Origin` (cross-origin requests are blocked). |
|
|
| 2 | `DnsRebindingProtectionMiddleware` | Validates `Origin`/`Host` against an allowlist. Defaults to localhost variants only. |
|
|
| 3 | `ProtocolVersionMiddleware` | Rejects requests carrying an unsupported `MCP-Protocol-Version` header with `400 Bad Request`. |
|
|
|
|
```php
|
|
// Zero-config, secure-by-default — local servers get full protection automatically.
|
|
$transport = new StreamableHttpTransport($request);
|
|
```
|
|
|
|
The default stack can be inspected and recomposed via the public factory:
|
|
|
|
```php
|
|
$middleware = StreamableHttpTransport::defaultMiddleware();
|
|
```
|
|
|
|
### CORS Configuration
|
|
|
|
CORS is handled by `CorsMiddleware`. To enable cross-origin browser requests, configure it explicitly and pass it
|
|
in place of (or alongside) the defaults:
|
|
|
|
```php
|
|
use Mcp\Server\Transport\Http\Middleware\CorsMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
|
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
// Reflect a specific origin
|
|
$transport = new StreamableHttpTransport(
|
|
$request,
|
|
middleware: [
|
|
new CorsMiddleware(allowedOrigins: ['https://myapp.com']),
|
|
new DnsRebindingProtectionMiddleware(),
|
|
new ProtocolVersionMiddleware(),
|
|
],
|
|
);
|
|
|
|
// Allow all origins (development only)
|
|
$transport = new StreamableHttpTransport(
|
|
$request,
|
|
middleware: [
|
|
new CorsMiddleware(allowedOrigins: ['*']),
|
|
new DnsRebindingProtectionMiddleware(),
|
|
new ProtocolVersionMiddleware(),
|
|
],
|
|
);
|
|
```
|
|
|
|
When the allowlist is a concrete set of origins (not `['*']`), `CorsMiddleware` automatically adds `Vary: Origin`
|
|
so shared caches/CDNs do not serve a response generated for one origin to a request from another.
|
|
|
|
Headers already present on a response (e.g. set by inner middleware) are preserved — `CorsMiddleware` only adds
|
|
defaults when they are absent.
|
|
|
|
> [!IMPORTANT]
|
|
> `Access-Control-Allow-Origin: *` is incompatible with credentialed browser requests (those carrying
|
|
> `Authorization`, cookies, or client certificates). If your MCP server runs OAuth/Bearer auth and serves
|
|
> a browser client, configure `allowedOrigins` with the explicit origin(s) you trust rather than `['*']`.
|
|
> The middleware reflects the matching origin verbatim, which is the form browsers accept with credentials.
|
|
|
|
### DNS Rebinding Protection
|
|
|
|
`DnsRebindingProtectionMiddleware` validates the `Origin` header against an allowlist (falling back to `Host`
|
|
when `Origin` is absent). The default allowlist is localhost-only:
|
|
|
|
```php
|
|
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
|
|
|
new DnsRebindingProtectionMiddleware(allowedHosts: ['myapp.local', 'mcp.internal']);
|
|
```
|
|
|
|
If the server is fronted by a reverse proxy that already validates `Host`, drop this middleware from the chain
|
|
or supply a permissive allowlist.
|
|
|
|
### Protocol Version Validation
|
|
|
|
`ProtocolVersionMiddleware` rejects requests whose `MCP-Protocol-Version` header is not in the SDK's supported
|
|
set with `400 Bad Request`. Requests without the header pass through, since the `initialize` round-trip and some
|
|
legacy clients do not send it.
|
|
|
|
```php
|
|
use Mcp\Schema\Enum\ProtocolVersion;
|
|
use Mcp\Server\Transport\Http\Middleware\ProtocolVersionMiddleware;
|
|
|
|
// Only accept the latest spec version
|
|
new ProtocolVersionMiddleware(supportedVersions: [ProtocolVersion::V2025_11_25]);
|
|
```
|
|
|
|
The default set is `ProtocolVersion::handshakeVersions()` — every revision the server can actually negotiate over
|
|
`initialize`, rather than every revision the enum declares. A request without the header is treated as
|
|
`ProtocolVersion::DEFAULT_HEADER_VERSION` (`2025-03-26`), the revision that introduced both Streamable HTTP and the
|
|
header itself, so a header-less request cannot be newer than that.
|
|
|
|
This header check is separate from, and happens after, the handshake itself. See
|
|
[Protocol Version Negotiation](server-builder.md#protocol-version-negotiation) for how the revision is agreed in the
|
|
first place. Being separate also means it is unaffected by `setProtocolVersion()`: the middleware validates against
|
|
the set it was constructed with, not against the revision a given session negotiated, so a server that pins the
|
|
handshake has to pass that revision here as well.
|
|
|
|
### Request Body Size Limit
|
|
|
|
`StreamableHttpTransport` caps the POST body it reads to guard against memory exhaustion from an oversized or
|
|
unbounded (chunked) payload. The default cap is 4 MiB. A body over the cap is rejected with `413` and never reaches
|
|
message parsing.
|
|
|
|
```php
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
// Raise the cap to 16 MiB
|
|
$transport = new StreamableHttpTransport($request, maxBodyBytes: 16 * 1024 * 1024);
|
|
```
|
|
|
|
When the request stream advertises a size, the transport rejects it up-front. Otherwise (e.g. chunked transfer with
|
|
unknown size) the body is read incrementally and aborted as soon as it crosses the cap, so an unbounded stream cannot
|
|
exhaust memory. A value below `1` throws `InvalidArgumentException`.
|
|
|
|
### JSON-RPC Batch Size Limit
|
|
|
|
A JSON-RPC batch (top-level array) is capped at 100 messages by default. Oversized batches are rejected before any
|
|
message is constructed, so a single small request cannot amplify into arbitrarily many operations. The cap lives on
|
|
`MessageFactory`:
|
|
|
|
```php
|
|
use Mcp\JsonRpc\MessageFactory;
|
|
|
|
$factory = MessageFactory::make(maxBatchSize: 50);
|
|
```
|
|
|
|
Single-message vs batch is determined from the decoded JSON type — a JSON object is a single message, a JSON array
|
|
is a batch. Scalars, empty payloads, and non-object batch elements are returned as `InvalidInputMessageException`
|
|
entries (the existing per-message error contract), not parse errors or crashes. A `maxBatchSize` below `1` throws
|
|
`InvalidArgumentException`.
|
|
|
|
### Custom PSR-15 Middleware
|
|
|
|
`StreamableHttpTransport` accepts any PSR-15 middleware chain. To extend the defaults, spread them and append
|
|
your own middleware — the defaults stay outermost so CORS headers are applied to every response, including
|
|
short-circuited ones:
|
|
|
|
```php
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
use Psr\Http\Message\ResponseFactoryInterface;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Psr\Http\Server\MiddlewareInterface;
|
|
use Psr\Http\Server\RequestHandlerInterface;
|
|
|
|
final class AuthMiddleware implements MiddlewareInterface
|
|
{
|
|
public function __construct(private ResponseFactoryInterface $responses)
|
|
{
|
|
}
|
|
|
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
|
{
|
|
if (!$request->hasHeader('Authorization')) {
|
|
return $this->responses->createResponse(401);
|
|
}
|
|
|
|
return $handler->handle($request);
|
|
}
|
|
}
|
|
|
|
$transport = new StreamableHttpTransport(
|
|
$request,
|
|
logger: $logger,
|
|
middleware: [
|
|
...StreamableHttpTransport::defaultMiddleware(),
|
|
new AuthMiddleware($responseFactory),
|
|
],
|
|
);
|
|
```
|
|
|
|
To selectively drop one default (for example DNS rebinding when running behind a proxy), filter the default list:
|
|
|
|
```php
|
|
use Mcp\Server\Transport\Http\Middleware\DnsRebindingProtectionMiddleware;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
$transport = new StreamableHttpTransport(
|
|
$request,
|
|
middleware: [
|
|
...array_filter(
|
|
StreamableHttpTransport::defaultMiddleware(),
|
|
fn ($m) => !$m instanceof DnsRebindingProtectionMiddleware,
|
|
),
|
|
new AuthMiddleware($responseFactory),
|
|
],
|
|
);
|
|
```
|
|
|
|
Pass `middleware: []` to disable every default and run only your own chain:
|
|
|
|
```php
|
|
$transport = new StreamableHttpTransport(
|
|
$request,
|
|
middleware: [new AuthMiddleware($responseFactory)],
|
|
);
|
|
```
|
|
|
|
### Architecture
|
|
|
|
The HTTP transport doesn't run its own web server. Instead, it processes PSR-7 requests and returns PSR-7 responses that
|
|
your application can handle however it needs to:
|
|
|
|
```
|
|
Your Web App → PSR-7 Request → StreamableHttpTransport → PSR-7 Response → Your Web App
|
|
```
|
|
|
|
This design allows integration with any PHP framework or application that supports PSR-7.
|
|
|
|
### Basic Usage (Standalone)
|
|
|
|
Here's a simplified example using PSR-17 discovery and Laminas emitter:
|
|
|
|
```php
|
|
use Http\Discovery\Psr17Factory;
|
|
use Mcp\Server;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
use Mcp\Server\Session\FileSessionStore;
|
|
use Laminas\HttpHandlerRunner\Emitter\SapiEmitter;
|
|
|
|
$psr17Factory = new Psr17Factory();
|
|
$request = $psr17Factory->createServerRequestFromGlobals();
|
|
|
|
$server = Server::builder()
|
|
->setServerInfo('HTTP Server', '1.0.0')
|
|
->setDiscovery(__DIR__, ['.'])
|
|
->setSession(new FileSessionStore(__DIR__ . '/sessions')) // HTTP needs persistent sessions
|
|
->build();
|
|
|
|
$transport = new StreamableHttpTransport($request);
|
|
|
|
$response = $server->run($transport);
|
|
|
|
(new SapiEmitter())->emit($response);
|
|
```
|
|
|
|
### Framework Integration
|
|
|
|
#### Symfony Integration
|
|
|
|
First install the required PSR libraries:
|
|
|
|
```bash
|
|
composer require symfony/psr-http-message-bridge nyholm/psr7
|
|
```
|
|
|
|
Then create a controller that uses Symfony's PSR-7 bridge:
|
|
|
|
> **Note**: This example assumes your MCP `Server` instance is configured in Symfony's service container.
|
|
|
|
```php
|
|
// In a Symfony controller
|
|
use Symfony\Component\HttpFoundation\Request;
|
|
use Symfony\Component\HttpFoundation\Response;
|
|
use Symfony\Component\Routing\Attribute\Route;
|
|
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
|
|
use Symfony\Bridge\PsrHttpMessage\Factory\HttpFoundationFactory;
|
|
use Mcp\Server;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
class McpController
|
|
{
|
|
#[Route('/mcp', name: 'mcp_endpoint')]
|
|
public function handle(Request $request, Server $server): Response
|
|
{
|
|
// Convert Symfony request to PSR-7 (PSR-17 factories auto-discovered)
|
|
$psrHttpFactory = new PsrHttpFactory();
|
|
$httpFoundationFactory = new HttpFoundationFactory();
|
|
$psrRequest = $psrHttpFactory->createRequest($request);
|
|
|
|
// Process with MCP (factories auto-discovered)
|
|
$transport = new StreamableHttpTransport($psrRequest);
|
|
$psrResponse = $server->run($transport);
|
|
|
|
// Convert PSR-7 response back to Symfony
|
|
return $httpFoundationFactory->createResponse($psrResponse);
|
|
}
|
|
}
|
|
```
|
|
|
|
#### Laravel Integration
|
|
|
|
First install the required PSR libraries:
|
|
|
|
```bash
|
|
composer require symfony/psr-http-message-bridge nyholm/psr7
|
|
```
|
|
|
|
Then create a controller that type-hints `ServerRequestInterface`:
|
|
|
|
> **Note**: This example assumes your MCP `Server` instance is constructed and bound in a Laravel service provider for dependency injection.
|
|
|
|
```php
|
|
// In a Laravel controller
|
|
use Psr\Http\Message\ServerRequestInterface;
|
|
use Psr\Http\Message\ResponseInterface;
|
|
use Mcp\Server;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
class McpController
|
|
{
|
|
public function handle(ServerRequestInterface $request, Server $server): ResponseInterface
|
|
{
|
|
// Create the MCP HTTP transport
|
|
$transport = new StreamableHttpTransport($request);
|
|
|
|
// Process MCP request and return PSR-7 response
|
|
// Laravel automatically handles PSR-7 responses
|
|
return $server->run($transport);
|
|
}
|
|
}
|
|
|
|
// Route registration
|
|
Route::any('/mcp', [McpController::class, 'handle']);
|
|
```
|
|
|
|
#### Slim Framework Integration
|
|
|
|
Slim Framework works natively with PSR-7.
|
|
|
|
Create a route handler using Slim's built-in factories and container:
|
|
|
|
```php
|
|
use Slim\Factory\AppFactory;
|
|
use Mcp\Server;
|
|
use Mcp\Server\Transport\StreamableHttpTransport;
|
|
|
|
$app = AppFactory::create();
|
|
|
|
$app->any('/mcp', function ($request, $response) {
|
|
$server = Server::builder()
|
|
->setServerInfo('My MCP Server', '1.0.0')
|
|
->setDiscovery(__DIR__, ['.'])
|
|
->build();
|
|
|
|
$transport = new StreamableHttpTransport($request);
|
|
|
|
return $server->run($transport);
|
|
});
|
|
```
|
|
|
|
### HTTP Method Handling
|
|
|
|
The transport handles all HTTP methods automatically:
|
|
|
|
- **POST**: Send MCP requests
|
|
- **GET**: Not implemented (returns 405)
|
|
- **DELETE**: End session
|
|
- **OPTIONS**: CORS preflight
|
|
|
|
You should route **all methods** to your MCP endpoint, not just POST.
|
|
|
|
### Session Management
|
|
|
|
HTTP transport requires persistent sessions since PHP doesn't maintain state between requests. Unlike STDIO transport
|
|
where in-memory sessions work fine, HTTP transport needs a persistent session store:
|
|
|
|
```php
|
|
use Mcp\Server\Session\FileSessionStore;
|
|
|
|
// ✅ Good for HTTP
|
|
$server = Server::builder()
|
|
->setSession(new FileSessionStore(__DIR__ . '/sessions'))
|
|
->build();
|
|
|
|
// ❌ Not recommended for HTTP (sessions lost between requests)
|
|
$server = Server::builder()
|
|
->setSession(new InMemorySessionStore())
|
|
->build();
|
|
```
|
|
|
|
### Recommended Route
|
|
|
|
It's recommended to mount the MCP endpoint at `/mcp`, but this is not enforced:
|
|
|
|
```php
|
|
// Recommended
|
|
Route::any('/mcp', [McpController::class, 'handle']);
|
|
|
|
// Also valid
|
|
Route::any('/', [McpController::class, 'handle']);
|
|
Route::any('/api/mcp', [McpController::class, 'handle']);
|
|
```
|
|
|
|
### Testing HTTP Transport
|
|
|
|
Use the MCP Inspector to test HTTP servers:
|
|
|
|
```bash
|
|
# Start your PHP server
|
|
php -S localhost:8000 server.php
|
|
|
|
# Connect with MCP Inspector
|
|
npx @modelcontextprotocol/inspector http://localhost:8000
|
|
```
|
|
|
|
## Choosing a Transport
|
|
|
|
The choice between STDIO and HTTP transport depends on the client you want to integrate with.
|
|
If you are integrating with a client that is running **locally** (like Claude Desktop), use STDIO.
|
|
If you are building a server in a distributed environment and need to integrate with a **remote** client, use Streamable HTTP.
|