Install via composer:
composer require boneframework/mcpEnable the package in your config/packages.php:
<?php
return [
'packages' => [
Bone\BoneDoctrine\BoneDoctrinePackage::class,
App\AppPackage::class,
Bone\MCP\MCPPackage::class,
],
];Register it after the packages that provide any services you use as middleware (for example Bone\OAuth2\BoneOAuth2Package).
Create config/mcp.php. Every key is optional; the defaults are shown:
<?php
return [
'mcp' => [
'path' => '/mcp',
'server_name' => 'Bone Framework MCP Server',
'server_version' => '1.0.0',
'instructions' => null, // sent to clients on initialize: tell agents how to use your tools
// where your tool classes live; relative paths are resolved from the application root
'discovery' => [
'base_path' => 'src',
'scan_dirs' => ['Mcp'],
'exclude_dirs' => [],
],
// sessions must outlive a single PHP request, so they are stored on disk
'session' => [
'directory' => 'data/cache/mcp-sessions',
'ttl' => 3600,
'lock_timeout' => 30, // seconds a request waits for another request on its session (then 503)
],
// Host headers accepted by the DNS rebinding protection (no ports)
'allowed_hosts' => ['localhost', '127.0.0.1', '[::1]'],
// cross-origin callers (browsers send Origin), e.g. 'https://app.example.com'; same-origin is always allowed
'allowed_origins' => [],
// PSR-15 middleware applied to the endpoint: container ids/class names or instances
'middleware' => [],
],
];The endpoint is open unless you add middleware. With delboy1978uk/bone-oauth2 installed,
require a Bearer token like this:
'middleware' => [Bone\OAuth2\Http\Middleware\ResourceServerMiddleware::class],If you serve the app on a hostname other than localhost, add it to allowed_hosts, e.g.
'allowed_hosts' => ['localhost', $_ENV['DOMAIN_NAME']], or every request gets
403 Forbidden: Invalid Host header.
Server-side clients send no Origin header. Browser-based clients on another origin must be
listed in allowed_origins (scheme, host and port), or they get 403.
Put tool classes in src/Mcp/ (or wherever discovery points) and mark public methods with
#[McpTool]. The input schema is generated from the parameter types and docblock.
<?php
namespace App\Mcp;
use Bone\MCP\RequestContext;
use Mcp\Capability\Attribute\McpTool;
use Mcp\Exception\ToolCallException;
class GreetingTools
{
public function __construct(private RequestContext $context) {}
/**
* Greet someone.
*
* @param string $name who to greet
*/
#[McpTool(name: 'greet')]
public function greet(string $name): array
{
if ($name === '') {
throw new ToolCallException('name is required'); // returned to the agent as a tool error
}
return ['greeting' => 'Hello ' . $name, 'client' => $this->context->getClientId()];
}
}Tool classes are resolved from the Bone container, so register any class with constructor
dependencies in your package's addToContainer():
$c[GreetingTools::class] = $c->factory(fn (Container $c) => new GreetingTools($c->get(RequestContext::class)));Bone\MCP\RequestContext holds the HTTP request being served: use getAttribute() for any request
attribute, or getClientId() for the oauth_client_id set by the OAuth resource server middleware.
Throw Mcp\Exception\ToolCallException for errors the agent should see; any other exception is
reported as an opaque internal error.
The endpoint speaks MCP's Streamable HTTP transport:
- POST
/mcp— JSON-RPC messages.initializereturns anMcp-Session-Idheader; send it on every later request - GET
/mcp— server-sent event stream for the session - DELETE
/mcp— end the session
POST /mcp
Content-Type: application/json
Accept: application/json, text/event-stream
Authorization: Bearer <token>
{"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "MyClient", "version": "1.0.0"}}}Note that clientInfo.version must be a non-empty string other than "0".
Clients may send several requests on one session at once (e.g. parallel tool calls). The package
configures the SDK's per-session lock (Mcp\Server\Session\FileSessionLock, flock on
<session id>.lock next to the session files): requests on the same session run one at a time
and each gets its own response, while different sessions still run in parallel. A request that
waits longer than session.lock_timeout is answered with 503. The session directory must be on
a filesystem that supports flock (local disks and Docker volumes do; some network filesystems
don't). The session store's garbage collection removes lock files of expired sessions.
vendor/bin/codecept run Unit --coverageContributions are welcome! Please feel free to submit a Pull Request.
- Derek McLean - @delboy1978uk
MIT License. See the LICENSE file for details.


