bherila / mcp-laravel-bridge
Shared MCP-over-REST infrastructure for Laravel applications.
Requires
- php: >=8.3 <8.6
- guzzlehttp/psr7: ^2.7 || ^3.0
- illuminate/contracts: ^12.0 || ^13.0
- illuminate/http: ^12.0 || ^13.0
- illuminate/routing: ^12.0 || ^13.0
- illuminate/support: ^12.0 || ^13.0
- mcp/sdk: ^0.7.1 || ^0.8.0
- psr/log: ^3.0
- symfony/psr-http-message-bridge: ^7.2 || ^8.0
Requires (Dev)
- orchestra/testbench: ^10.0 || ^11.0
- phpunit/phpunit: ^12.0 || ^13.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-10-10 19:28:51 UTC
README
Shared, domain-neutral infrastructure for Bherila Laravel applications that expose an explicit MCP catalog over a canonical versioned REST API.
The package provides:
- an in-process REST transport that forwards only bearer credentials, caller address, and explicitly allow-listed headers;
- JSON, multipart upload, request-binding restoration, and temporary-file cleanup support;
- OpenAPI operation-to-request/response schema resolution with transitive local reference packaging;
- original-wire MCP argument preservation, strict reflected input schemas, and privacy-safe runtime output validation;
- Streamable HTTP PSR/Laravel response conversion and credential-isolated session namespaces.
- a hardened Laravel HTTP edge profile with exact Origin and independent Host enforcement, bounded envelopes, private responses, and a version-aware SDK middleware selection;
- a synthetic fixture server and assertions for application conformance tests.
Applications own authorization, domain actions, response content, write-flag configuration and server instructions. Since 0.3, an application can declare each operation once in a capability registry. The package then derives the MCP tools and the availability view from that declaration; OpenAPI and routing follow in 0.4. It never exposes an operation the application did not declare.
Installation
The package supports PHP 8.3–8.5, Laravel 12/13, and the official PHP MCP SDK
0.7/0.8. Install it as a runtime dependency: MCP routes must continue to work
after composer install --no-dev.
composer require bherila/mcp-laravel-bridge:^0.2.0
If Composer cannot yet discover a new release through Packagist, declare the
public VCS repository in the consuming application's root composer.json:
{
"repositories": [
{"type": "vcs", "url": "https://github.com/bherila/mcp-laravel-bridge"}
]
}
Hardened HTTP integration
Use McpHttpSecurityMiddleware outside OAuth/authentication middleware so that
preflight responses, authentication challenges, and failures receive the same
CORS and private-cache policy. Bind one policy in the application container;
closure-backed lists are re-evaluated on every request and are suitable for
long-lived workers.
use Bherila\McpLaravelBridge\Http\McpHttpPolicy; $this->app->singleton(McpHttpPolicy::class, fn () => new McpHttpPolicy( allowedOrigins: fn (): array => config('agent_api.mcp_allowed_origins', []), allowedHosts: fn (): array => array_values(array_unique([ McpHttpPolicy::hostFromUrl((string) config('app.url')), McpHttpPolicy::hostFromUrl((string) config('bherila-auth.oauth_server.resource')), ])), maxRequestBodyBytes: (int) config('agent_api.mcp_max_body_bytes', 262_144), maxResponseBodyBytes: (int) config('agent_api.mcp_max_response_bytes', 1_048_576), ));
Register the route in this order:
use Bherila\McpLaravelBridge\Http\McpHttpSecurityMiddleware; Route::match(['POST', 'DELETE'], '/api/v1/mcp', AgentMcpController::class) ->middleware([ McpHttpSecurityMiddleware::class, ExpectOAuthResource::class, 'auth:api', CheckToken::using('mcp:use'), 'throttle:60,1', ]); Route::options('/api/v1/mcp', AgentMcpController::class) ->middleware(McpHttpSecurityMiddleware::class);
Browser origins are canonical HTTP(S) origins. Scheme and non-default port are
part of the match; host and scheme casing is normalized. Default :80 and
:443 are omitted to match browser Origin serialization. Wildcards, paths,
userinfo, query strings, and fragments are rejected. Native clients without an
Origin remain supported.
The service Host is checked separately even when Origin is present. Allowed
Hosts are exact authorities (host or host:port) and must come from trusted
application/resource configuration. Browser origins never expand this list.
hostFromUrl() is the supported derivation helper and preserves non-default
ports. The bridge deliberately ignores Forwarded and X-Forwarded-Host;
configure Laravel trusted proxies and the externally accepted authority
explicitly instead of trusting arbitrary forwarded headers.
Query-string bearer tokens/API keys are rejected. The edge never logs request
arguments, results, credentials, or URLs. Every response is marked private,
no-store, and nosniff; approved browser callers receive MCP session/version
plus Mcp-Method, Mcp-Name, and WWW-Authenticate exposure. The default
preflight policy accepts the corresponding MCP 2026 request headers, including
Mcp-Name; custom header lists are deduplicated case-insensitively. An
unapproved OPTIONS request returns 204 with no Access-Control-Allow-Origin,
while an unapproved non-OPTIONS request is rejected before execution.
Controllers can either route-register the middleware and use the responder, or
use McpHttpEndpoint as the all-in-one composition:
public function __invoke( Request $request, AgentMcpServerFactory $servers, McpHttpEndpoint $endpoint, McpHttpPolicy $policy, ): Response { return $endpoint->run( $request, fn (Request $request): Server => $servers->make($request), $policy, ); }
The server factory form ensures invalid requests and OPTIONS preflights do not
construct an authenticated server. A preconstructed Server remains supported
for simple endpoints. Do not apply both integrations to the same route. With route middleware, call
StreamableHttpResponder::run() using
SdkMiddlewareProfile::forHardenedLaravelEdge(). SDK 0.7 receives its required
protocol-version middleware; SDK 0.8+ lets the transport classify the modern
lifecycle before applying its handshake-only checks.
Request bodies are bounded by both an early Content-Length check and the SDK's
bounded reader. Non-streaming responses and known-length streams are bounded at
the Laravel edge. Applications must additionally keep each tool output schema
closed and bounded (maxLength, maxItems, and closed object properties),
which bounds individual messages carried by a long-lived SSE response.
For consumer tests, SyntheticMcpServerFactory::make() supplies a domain-free
bridge.ping tool whose handler directly invokes a callable application
service. McpHttpConformanceAssertions checks the common privacy and CORS
contract. This pattern intentionally avoids internal HTTP re-entry:
$server = SyntheticMcpServerFactory::make( fn (): array => $applicationService->ping(), );
Tool authentication metadata
ToolWithSecuritySchemes extends the official PHP SDK's Tool value object so
applications can publish current-client per-tool authentication requirements
without rewriting tools/list responses. It emits both the top-level
securitySchemes field and the identical _meta.securitySchemes compatibility
mirror required by the OpenAI authentication
flow and tool metadata
reference. Register it through
the SDK registry or Server::builder()->add() just like an SDK Tool:
use Bherila\McpLaravelBridge\Mcp\ToolWithSecuritySchemes; $tool = new ToolWithSecuritySchemes( securitySchemes: [[ 'type' => 'oauth2', 'scopes' => ['mcp:use', 'records:read'], ]], name: 'records.list', title: 'List records', // Empty JSON object schemas must remain objects on SDK 0.7. inputSchema: ['type' => 'object', 'properties' => (object) []], description: 'Lists authorized records.', annotations: $annotations, ); $registry->registerTool($tool, $handler);
Only noauth and oauth2 schemes are accepted. Scheme counts, aggregate scope
counts, individual RFC 6749 scope tokens, and serialized size are bounded.
Unknown fields, duplicate schemes/scopes, and conflicting compatibility mirrors
fail closed. This metadata never authorizes an invocation: applications must
still validate the bearer token, issuer, audience/resource, expiry, revocation,
client, and scopes before accessing data.
This adapter is needed through official mcp/sdk v0.8.1, whose Tool schema
does not yet expose the top-level field. The bridge supports and tests both the
v0.7 and v0.8 SDK lines. Prefer the upstream SDK representation once one is
released and proven compatible.
Pass null as StreamableHttpResponder::run()'s middleware argument only when
using the SDK's local-only defaults. Public Laravel routes should use the
hardened edge profile above. With mcp/sdk v0.8+, do not place
ProtocolVersionMiddleware in a custom edge stack: the SDK applies it only
after classifying handshake-era requests, allowing the same endpoint to serve
the modern 2026-07-28 lifecycle.
Schemas and safe validation
LocalOnlySchemaValidator is the transport-independent validation seam.
OriginalShapeSchemaValidator extends it while preserving JSON object/list
distinctions from the original MCP request. Both reject non-fragment $ref
values before Opis can resolve them, and use a null upstream logger so internal
validation errors cannot log payloads or schemas. The optional application
logger receives metadata only.
SchemaCatalog packages transitive OpenAPI component references into local
$defs and now rejects every external, file, or relative reference. Generated
tool schemas therefore remain self-contained and cannot initiate network or
filesystem retrieval.
Sessions
CredentialSessionNamespace::prefix() hashes the current bearer/access-token
identity. It fails closed when no credential identity is available rather than
sharing one anonymous session namespace. Protocol sessions carry negotiation
state only; applications must keep durable jobs and leases in their own stores
and re-check authorization on every claim, read, download, and completion.
Upgrading from 0.1
Version 0.2.0 retains the existing transport, schema catalog, tool definition,
and validation APIs. Consumers should move to ^0.2.0, bind
McpHttpPolicy, replace application-owned Origin/Host middleware with
McpHttpSecurityMiddleware, and use
SdkMiddlewareProfile::forHardenedLaravelEdge() instead of constructing
ProtocolVersionMiddleware directly. See CHANGELOG.md for the
session and schema-validation fail-closed changes to account for during the
upgrade.
Long-lived workers
The internal REST transport temporarily replaces Laravel's container-bound request while an in-process subrequest runs and always restores it after success or failure. This is safe under normal PHP request execution. Concurrently interleaved requests in the same process (including an Octane task model that permits interleaving during dispatch) are not supported; deploy the bridge in a non-interleaving request context or provide an isolated transport adapter.
Capability registry
Declare each agent operation once. The package derives MCP tools, security schemes, annotations and an availability view with reasons. OpenAPI generation and route binding arrive in 0.4.
use Bherila\McpLaravelBridge\Capabilities\{Effect, IdempotencyKey, McpBinding, Operation, OperationRegistry, Requirement, RestBinding, SchemaRef, WriteSafety}; $registry = (new OperationRegistry)->register(new Operation( id: 'things.create', title: 'Create thing', description: 'Create a thing.', effect: Effect::LocalWrite, requirement: new Requirement(scopes: ['things:write'], flags: ['writes']), safety: new WriteSafety(idempotencyKey: IdempotencyKey::HeaderAndArgument, expectedVersion: true), rest: new RestBinding('POST', '/things', routeName: 'api.things.store', successStatuses: [201]), mcp: new McpBinding(handler: [ThingTools::class, 'create']), input: SchemaRef::requestOf('things.create'), // spec-first; or an inline array (code-first) output: SchemaRef::responseOf('things.create'), ));
Availabilitytakes yourPrincipaladapter (scopes, permissions, groups),DeploymentFlags(ConfigDeploymentFlagssupports nested cutovers and a first-party bypass) and an optionalOperationPolicy.evaluate($principal)returns the available operations, plus the withheld ones with a reason an agent can relay.implemented()is the flags-only view that server capabilities should advertise.
OperationToolFactoryturns available operations intoToolWithSecuritySchemes, or intoToolDefinitions for an existing server factory. PassconnectionScopes: ['mcp:use'](or whatever your MCP endpoint checks) so every advertised scheme includes it.- Requirements:
new Requirement([...scopes])(All or Any rule), permissions, a group and deployment flags.Requirement::publicAccess()needs nothing.Requirement::authenticated()needs any credential and no particular scope, e.g. self-revocation. ImplementAuthenticatedPrincipal::isAuthenticated()for it; a plainPrincipalis withheld with reasonunauthenticated. - Deriving an operation:
$operation->with(rest: new RestBinding('GET', '/things'))returns a copy with those fields replaced and every other field kept. - Spec-first:
SchemaCatalog::operations()lists every documented operation with its method, path andsecurityas written.securityis[]when explicitly public andnullwhen absent. Use it to register the REST operations no tool fronts. - Contract test: pin
$registry->contractViolations() === []in a test.
OpenAPI document
OpenApiDocumentBuilder generates the REST contract from the registry. Every URL comes from OpenApiSettings, so take them from the application's configuration, never a literal host:
$builder = new OpenApiDocumentBuilder($registry, new OpenApiSettings( title: 'Things API', version: '1', serverUrl: config('bherila-auth.oauth_server.resource'), authorizationUrl: config('bherila-auth.oauth_server.authorization_endpoint'), tokenUrl: config('bherila-auth.oauth_server.token_endpoint'), scopes: Scopes::descriptions(), connectionScopes: ['mcp:use'], // operations needing these are OAuth-only extraMediaTypes: ['application/toon'], // optional, beside application/json ), $catalog); // a SchemaCatalog when operations use SchemaRef return response()->json($builder->full()); // or ->filtered($availability, $principal)
-
Security: each operation lists its alternatives.
- OAuth with all the scopes at once, or one alternative per scope for an
Anyrule. - A personal API token, unless the operation needs a connection scope.
- Nothing for a public operation.
- OAuth with no scopes for
Requirement::authenticated().
A credentialed operation that no offered scheme can carry is refused, so it never reads as public.
- OAuth with all the scopes at once, or one alternative per scope for an
-
Parameters and bodies: path parameters come from
RestBinding::$pathParameters. A GET, HEAD or DELETE input becomes query parameters; any other input becomes the request body. AnIdempotency-Keyheader parameter is added for header idempotency keys. -
Extensions: effect, idempotency, write safety, MCP tool name and dependencies are emitted under
x-agent-…, with the prefix configurable. Declaredextensionsare added too. -
Spec-first migration:
OpenApiDocumentBuilder::differences($builder->full(), $shippedPath)lists JSON-pointer mismatches against a shipped document. Switch to generation when the list is empty;write()saves the result.
Reproducing a hand-written document (0.4.1)
Every option below is opt-in; with none set, the document is byte for byte what 0.4.0 generated.
new RestBinding('DELETE', '/things/{thing}', description: 'Deletes one thing at its current version.', // or false for none; null keeps the operation's parameters: ['ThingId', 'IdempotencyKey', ['name' => 'reason', 'in' => 'query', 'schema' => ['type' => 'string']]], requestSchema: SchemaRef::openApi('ExpectedVersion'), // the REST body; also how a DELETE documents one; false for none responseSchema: SchemaRef::openApi('Deletion'), // when the REST response differs from the MCP output responseDescriptions: [200 => 'Thing deleted'], responses: [404 => 'NotFound', 200 => [/* a whole response object, e.g. a binary download */]], requestBodyRequired: true, ); new OpenApiSettings(..., summaries: false, // no summary unless a binding declares one agentExtensions: false, // no x-agent-*; an operation's own `extensions` are still emitted parameters: ['ThingId' => [...], 'IdempotencyKey' => [...]], // components/parameters responses: ['Error' => [...], 'NotFound' => [...]], // components/responses sharedResponses: ['default' => 'Error'], // added to every operation apiTokenBearerFormat: 'JWT', oauthDescription: '...', refreshUrl: false, );
- Declared parameters are exact and ordered: a string names a component (emitted as
$ref), an array is inlined. They must cover every path placeholder once, mark path parameters required, and document theIdempotency-Keyheader the operation reads. With them, a GET or DELETE input is no longer turned into query parameters. - Responses are the success statuses, then the binding's
responses(replacing a success response of the same status), thensharedResponsesfor statuses still undescribed. - References in declared fragments must resolve: a schema through the
SchemaCatalog(and it is carried intocomponents/schemas), a parameter or response through the settings. External references are refused. - Drift check:
OperationRegistryAssertions::assertOpenApiDocumentMatches($builder->full(), $checkedPath)fails when a checked copy differs from the generated document in content or bytes.UPDATE_OPERATION_SNAPSHOTS=1rewrites it.
Payload codecs (0.4.2)
An application that also speaks an encoding other than JSON (for example a compact text format for language-model clients) implements PayloadCodec and binds the collection:
$this->app->instance(PayloadCodecs::class, new PayloadCodecs(new ToonCodec)); // the bridge ships no codec
- Routes: with a non-empty collection bound,
Route::operation()addsNegotiatePayloadafter the gate. A body in a codec's media type is decoded into the JSON input the controller already reads, where the operation takes a JSON (or+json) body or lists that type. Otherwise it is refused with 415, including a body sent to an operation that documents none; a body the codec rejects withInvalidArgumentExceptiongets 400. A JSON response, errors included, is re-encoded when theAcceptheader ranks a codec's type above JSON; one with an integer beyond PHP's range stays JSON, and validators of the JSON bytes (ETag, digests) are dropped. JSON stays the default and wins ties, gate refusals stay JSON, and negotiated routes addVary: Accept. - Document:
new OpenApiDocumentBuilder($registry, $settings, $catalog, $codecs)offers each codec's type beside every JSON request and response body.extraMediaTypesstill works;mediaTypesWithoutCodec()lists declared types nothing serves. - Test:
assertMediaTypesHaveCodecs($registry, $settings, $codecs)fails on a declared type (an extra media type or a binding's request type) with no codec, beyond the types Laravel parses itself.
With no codec bound, routes and documents are exactly as in 0.4.1.
REST routes and the gate
OperationRoutes::macro(); // once, e.g. in a service provider Route::prefix('api/v1')->name('api.')->group(function () { Route::operation('things.list', [ThingController::class, 'index']); });
Route::operation() registers the route from the operation's REST binding and adds GateOperation:<id>. A declared routeName is the route's full name, including any enclosing group's prefix. Without one, the route is named after the operation id. Path parameters come from the path's placeholders and take their schemas from the input.
The middleware evaluates the container's Availability for the principal your bound PrincipalResolver returns. A withheld operation answers with {message, operation, reason, detail}:
- 401 when no credential authenticated the caller;
- 403 otherwise, with an RFC 6750
insufficient_scopechallenge naming the missing scopes.
Test helpers
use Bherila\McpLaravelBridge\Testing\OperationRegistryAssertions; in a feature test:
-
assertOperationRegistryContract($registry, $builder, ['mcp:use']): no contract violations, declared route names exist, and every non-connection operation accepts both OAuth and API tokens. -
assertWebRoutesClassified($registry, $classification): every state-changing web route is classified as one of:operation:<id>;mcp-only:<id>;missing;web-only:<reason>;deliberate:<reason>.
It fails on an unclassified route, a stale entry, or an
mcp-onlyentry whose REST binding has landed. -
assertScopeInventory($registry, $path)andassertVisibilitySnapshot($visibleByPrincipal, $path): reviewed JSON snapshots. Run withUPDATE_OPERATION_SNAPSHOTS=1to rewrite one for an intended change.