particle-academy / prism-mcp
Model Context Protocol for Prism — consume remote MCP tools as Prism tools, across a trust boundary you can see.
Requires
- php: ^8.2
- ext-json: *
- ext-mbstring: *
- laravel/framework: ^12.61.1|^13.12.0
- particle-academy/prism: >=0.114 <1.0
Requires (Dev)
- laravel/pint: ^1.30
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- phpstan/phpstan: ^2.0
Suggests
- laravel/mcp: Expose your own application's tools over MCP. prism-mcp consumes servers; laravel/mcp serves them.
- particle-academy/laravel-fms: Gate remote MCP tools on Fancy's Feature Management System (FeatureGate).
Replaces
README
Model Context Protocol for Prism. Tools from a server you do not own, offered to your model as ordinary Prism tools — across a trust boundary you can see.
use Prism\Mcp\Facades\PrismMcp; use Prism\Prism\Prism; $tools = PrismMcp::server('github')->tools(); Prism::text() ->using('anthropic', 'claude-sonnet-4-5') ->withTools($tools) ->withPrompt('Which of our open PRs touch the billing module?') ->asText();
Status: first slice. The client, over Streamable HTTP, speaking MCP revision
2026-07-28. What is deliberately not here yet is listed under Deferred — as a plan, not an oversight.
This package follows the Prism model. The model is described once, in
prism-parity/docs/patterns/,
and the decisions binding every package in the ecosystem live in
docs/decisions/.
Nothing here restates them, because restated documentation drifts exactly like
restated code and nothing tests prose.
Working on this package? Read
AGENTS.mdfirst — the boundary this package has to hold, the gates that must be green, and the traps that have already caught someone.@link AGENTS.md
The trust boundary
This is the section to read. Everything else in this package is plumbing.
What you are actually trusting
When you connect to an MCP server, you are not fetching data. You are letting a third party write into your model's instructions, and then letting your model act on what they write back.
Concretely, all of the following are authored by whoever operates that server, and all of them reach the model as text it treats as authoritative:
| Surface | When it arrives | What it can do |
|---|---|---|
| Tool name | discovery | Shape what the model thinks the tool is for |
| Tool description | discovery | Arbitrary instructions, read every turn |
| Parameter names and descriptions | discovery | The same, once per parameter |
| Enum values in the input schema | discovery | The same again, and rarely reviewed |
| Tool annotations | discovery | Claims about safety that may simply be false |
| Tool results | every call | Instructions delivered mid-run, pre-framed as trusted output |
Two properties make this different from every other untrusted input a Laravel application already handles well.
It is instructions, not data. You escape user input before it reaches HTML and bind it before it reaches SQL. There is no equivalent escape for text that reaches a language model, because instruction and data are the same channel. The MCP specification says so itself: "descriptions of tool behavior such as annotations should be considered untrusted, unless obtained from a trusted server."
It can change under you. A tool your application wrote changes when someone
deploys. A tool a third party publishes changes whenever they like, between two
calls, with nothing on your side to notice. A server can publish a benign
search tool, wait to be adopted, and then rewrite its description. The MCP
security literature calls that a rug pull, and no amount of reviewing the tool
once prevents it.
And what an injected instruction can reach is not limited to that server. Once the model is following it, every other tool in the same run is available — your own database tools, your own mail tools. A compromised MCP server is a foothold in the whole tool set, not just in its own.
What this package does about it
Four things, in order of how much they actually buy. This ordering is deliberate, and the honesty about the last one matters as much as the first.
1. Nothing reaches the model until you say so
A server with no trust declaration refuses, and it refuses before any request is sent — so a misconfigured application never even tells the server it has an audience.
// config/prism-mcp.php 'github' => [ 'url' => env('MCP_GITHUB_URL'), 'trust' => [ 'tools' => ['search_repositories', 'get_file_contents'], ], ],
Or, when you genuinely accept whatever that server publishes today and publishes next week — reasonable for a server you operate yourself:
'trust' => ['tools' => '*'],
There is no way to skip this. PrismMcp::client('https://…/mcp')->tools() — the
ad-hoc form people reach for when trying a server out — refuses just as firmly,
because exempting the exploratory path puts the hole exactly where the
exploration happens.
An empty allowlist is a separate, louder refusal from a missing one. An
empty one looks configured and silently hands the model zero tools, and a run
with no tools reads as the model choosing not to use any — which is precisely
the failure mode Prism core's Perplexity withTools() incident taught this
ecosystem to refuse rather than tolerate.
2. Pin a definition and a rewrite is refused, not obeyed
Opt-in, per tool. Record the digest of a definition you have actually read:
'trust' => [ 'tools' => ['search_repositories'], 'pins' => ['search_repositories' => 'sha256:…'], ],
php artisan prism-mcp:pins github
It prints a digest per tool and a trust block ready to paste, and it reads the
definitions without offering anything to a model — you run it precisely
BEFORE the tools are trusted, in order to decide what to trust. So it works
against a server with no trust declaration at all, which every other path in
this package refuses.
--tool=name narrows it, --json makes it scriptable, and a name the server
does not publish is reported rather than quietly dropped: returning fewer rows
than were asked for is how somebody pins three of four tools and believes they
pinned four.
Descriptions are stripped of control characters on the way to your terminal. They are written by the party you are inspecting, and a terminal renders ANSI escapes — the output of a command whose whole job is look at this before you trust it must not be paintable by whoever is being looked at.
The digest covers name, title, description and input schema — everything the model will read. It deliberately excludes annotations, which the spec already tells clients to distrust, and it sorts keys recursively so a server reordering its JSON does not read as a rewritten tool.
This is the one defence against a rug pull that actually holds, because it does not depend on recognising malice — only on noticing change.
3. Results are bounded, framed, and filterable
The discussion around MCP focuses on tool descriptions. The result path is worse and gets less attention: a description is read once at discovery, while a result arrives mid-run, already framed as the trusted output of a tool the model itself chose to call.
- Bounded. Over
max_result_bytes(256 KB by default) the call is refused, not truncated. A silently cut result reads to a model as a complete one. This is the piece that carries the most weight, and it is a stability and cost property before it is a security one — an unbounded result evicts your system prompt and lets a remote party choose how many tokens you pay for. - Framed. Results are wrapped in a delimiter naming the source server, with a per-call random nonce so a server that learns the delimiter cannot close it early and have the rest of its output read as trusted text again.
- Filterable.
->filteringResults(fn ($text, $server, $tool) => …)runs before framing, for applications that know what a given server should return.
The error path gets exactly the same treatment. Prism turns a thrown tool
exception into a ToolError the model reads, so isError: true is the identical
channel from an attacker's point of view — an unguarded error path would be a
hole straight through the framing and the cap, openable by any server willing to
set one boolean. (It was one, in the first draft of this package. The review
found it and the test that proves it is fixed is
it('does not let an error result bypass the guard').)
3b. A tool that is dropped is dropped visibly
A tool whose x-mcp-header annotations break the spec's rules is excluded
from the list — the specification makes that a client MUST, and one malformed
tool should not make an otherwise healthy server unusable.
That exclusion is this package's one carve-out from the ecosystem rule that things which cannot be done throw rather than degrade quietly. So it is bounded at both ends:
- Under
trustingEveryTool()the tool is dropped, and the exclusion is readable from->client()->excluded()— not only from a log line, because nobody reads a log during a run. - Under an explicit allowlist naming that tool, it throws. You asked for
that tool by name; returning successfully without it is the Perplexity
withTools()failure exactly — a run that completes with the model appearing to decline a tool it was never given.
4. A gate decides who may run what
Trust decides which tools the model is told about. A gate decides whether a given actor may run one. Different questions, and both worth answering.
// Laravel's Gate — authorization. Gate::define('mcp.call', fn (?User $user, string $server, string $tool, array $args) => $user?->can('use-external-tools') ?? false);
Adapters ship for Laravel Gates and for Fancy's Feature Management System, and neither is a hard dependency — see Gating for why they answer different questions and which to reach for.
What this package does NOT do, and will not pretend to
It does not scan results for injection. No regex, no heuristic, no "suspicious phrase" list. Pattern-matching for prompt injection produces false confidence and false positives, and MCP's own maintainers have published the argument plainly: nothing in static metadata "tells the model to ignore malicious instructions it reads", and a guarantee against exfiltration "is a job for network controls or sandboxing, not a boolean hint." Shipping a check that does not hold is worse than shipping none, because someone will rely on it.
Framing is a mitigation, not a fix. The delimiter gives the model the information it needs to distrust the text — which it otherwise does not have. A determined injection can still work. Anyone telling you their MCP client makes remote tools safe is selling something.
Annotations are not a safety mechanism. readOnlyHint: true is a claim by a
party you do not control. This package passes annotations through and pins
nothing to them.
Trust is not transitive. Trusting a server's search tool is not trusting
what search returns, and it is certainly not trusting whatever server that
server talks to.
One thing this package cannot guard, so guard it yourself
A server URL must never come from user input.
PrismMcp::client($url) fetches whatever you point it at, and this package does
not block private or loopback addresses — because http://127.0.0.1:3000/mcp is
how you run a local MCP server, and refusing it would break the ordinary case to
prevent a misuse. So the SSRF boundary is yours: server URLs belong in config or
in an operator-controlled setting, never in a request parameter.
The same goes for the gate key, which resolves a class name from the
container. Both are configuration surfaces, and configuration is trusted here in
the way every Laravel package trusts it.
The residual risk, stated plainly
With every feature here switched on, a malicious server can still: emit an instruction inside a result that the model follows; make its tool's legitimate purpose subtly wrong in a way review does not catch; and observe every argument your model sends it. What this package buys you is that these are decisions you made rather than defaults you inherited, that a definition cannot change underneath you unnoticed, and that the blast radius is bounded in size, in permission and in which tools were ever offered.
For a genuinely adversarial server, the answer is network controls and process isolation, and it always was.
Installation
composer require particle-academy/prism-mcp php artisan vendor:publish --tag=prism-mcp-config
Requires PHP 8.2+, Laravel 12.61+ or 13.12+, and
particle-academy/prism 0.114+.
Usage
A configured server
$tools = PrismMcp::server('github')->tools();
Every configured server at once
$tools = PrismMcp::tools();
A server that refuses is not skipped — the exception surfaces. A helper that quietly dropped an untrusted or unreachable server would hand the model a shorter tool list than the application believes it configured, and that silence is the thing this package exists to remove.
Ad hoc, with trust declared inline
$tools = PrismMcp::client('https://mcp.example.com/mcp') ->withToken(config('services.example.token')) ->trusting(['search']) ->withTimeout(10) ->tools();
The builder
| Method | Does |
|---|---|
->trusting([...]) |
Allowlist the named tools |
->trustingEveryTool() |
Accept whatever the server publishes |
->pinning([$tool => $digest]) |
Refuse a tool whose definition changed |
->withTimeout($seconds) |
Bound every call to this server |
->withToken($token) / ->withHeaders([...]) |
Authenticate |
->usingGate($gate) |
Decide who may run a trusted tool |
->filteringResults($closure) |
Transform results before framing |
->withMaxResultBytes($bytes) |
Change the refusal threshold |
->withoutProvenanceFraming() |
Stop framing results (not recommended) |
->withoutCache() / ->cachingFor($seconds) |
Control tool-list caching |
->client() |
The raw protocol client, unguarded |
Every method returns the builder and nothing is validated on the way in; the
checks all happen at tools(). That is Prism's
pending-request pattern,
applied here.
Performance
Tool discovery is cached. Without it, every generation pays a protocol negotiation plus a network round trip before the model sees a single token — a slow MCP server becomes a slow application, and the cost is paid on requests that never call a tool at all.
- The TTL comes from the server's
ttlMs, which revision2026-07-28requires it to send, unless configuration overrides it. - A warm cache short-circuits everything, including negotiation. A cache that still paid for a handshake would halve the saving for no reason.
- A result marked
cacheScope: privateis never written to a shared cache. - Across paginated pages, the tightest TTL wins.
A slow server cannot stall a generation. Every call is bounded, and the bound
failing is ServerTimedOut — distinct from TransportFailure, because one is
retryable and the other usually is not.
A large server cannot exhaust a worker. A timeout bounds how long a server may take; a separate 8 MB transport bound covers how much it may send, because the body is decoded into memory before any tool-level size check can look at it.
Cache entries are keyed on credentials, not just the URL. A server may
legitimately return a different tool list per caller and only some of them label
that cacheScope: private; keying on the URL alone would serve one principal's
tools to another the moment a server forgot the label. The key carries a hash,
never the credential — cache keys end up in logs.
Gating
Two adapters ship. Neither is required, and they answer different questions.
| Question | Reach for it when | |
|---|---|---|
LaravelGate |
Authorization — may this actor perform this action? | "May this user run delete_repository?" |
FeatureGate (FMS) |
Entitlement — is this subject granted this capability? | "Which servers may this tenant reach at all?" |
They compose, and conflating them has a specific failure mode: wire entitlement
to an authorization question and someone's lapsed subscription becomes what
stops a destructive action. FMS's own contract makes the distinction explicitly —
it added isEntitled() as a named alias so a call site declares which question
it is asking.
LaravelGate asks one ability, mcp.call — the ecosystem's
<package>.<verb> convention — receiving
($server, $tool, $arguments). It does not derive an ability per tool, because
an ability nobody defined is denied by default — so a third party adding a tool
could otherwise break your authorization by publishing something.
Bring your own by implementing Prism\Mcp\Contracts\ToolGate and naming the
class in gate. When prism-harness grows tool permissions, it implements the
same contract and nothing here changes.
particle-academy/laravel-fmsrequires PHP 8.4 and Laravel 13. It is asuggestrather than arequireso this package keeps the same PHP 8.2+ / Laravel 12+ floor as the rest of the ecosystem.
Errors
Every failure carries a stable code. The sentence is for a human and is
explicitly outside the contract — branch on code(), never on getMessage().
See decision 0004.
| Code | Raised when |
|---|---|
server_not_configured |
No such server, or it has no URL |
server_not_trusted |
No trust declaration, or an empty allowlist |
tool_definition_changed |
A pinned definition no longer matches |
tool_denied |
The gate refused this call |
result_too_large |
A result exceeded max_result_bytes |
tool_call_failed |
The server reported isError |
mirrored_parameter_refused |
A tool's x-mcp-header annotations broke the rules, and it was named in the allowlist |
unsupported_protocol_version |
The server speaks a different MCP era |
unsupported_transport |
stdio, or an unknown transport |
protocol_failure |
Malformed JSON-RPC, or a JSON-RPC error |
transport_failure |
Unreachable, or a non-2xx status |
server_timed_out |
The bound was hit |
Protocol
Speaks MCP revision 2026-07-28 — the current release — over Streamable
HTTP.
That revision is a rewrite rather than an increment: initialize was removed
outright and the protocol became stateless, with every request carrying its
own protocol version, client capabilities and client info in _meta. Sessions,
SSE resumability and the GET stream endpoint are gone. Revisions from
2025-11-25 back are a different protocol wearing the same name.
So a server on an older era is refused by name — an error stating what it speaks and what this client speaks — rather than silently downgraded. Legacy-era support is deferred, not forgotten.
Implemented from the revision, including the parts a client MUST do:
server/discovernegotiation, with-32022turned into a refusal naming both sides- Required
_meta(protocolVersion,clientCapabilities,clientInfo) and required headers (MCP-Protocol-Version,Mcp-Method,Mcp-Name) - Mirrored parameters —
x-mcp-headercopied intoMcp-Param-*, with the spec's full validation and the exclusion of any tool that breaks it. That exclusion is a security property: the annotation moves a model-supplied value into an HTTP header, where a CRLF would be header injection and every intermediary can read the result. Values that are not plain printable ASCII travel base64 in a sentinel. - Cursor pagination, bounded against a server that paginates forever
ttlMs/cacheScopehonoured- Both
application/jsonandtext/event-streamresponses, taking the last JSON-RPC frame — the first is a progress notification, not an answer - Response-id correlation, so a response for a different request is never returned as an answer
Protocol drift is watched: prism-provider-watch tracks the MCP spec and
laravel/mcp releases as an mcp target, so a revision arrives as a drift
report rather than a surprise.
Deferred
Present in the protocol, deliberately absent here. Listed so an absence reads as a plan rather than an oversight — decision 0003.
| Not yet | Why |
|---|---|
| stdio transport | Spawns a local child process from a command string. A materially different risk class from fetching a URL, and it lands behind an explicit opt-in rather than as a peer of one. |
| Legacy protocol eras | 2025-11-25 and older use the removed initialize handshake — a second protocol, not a compatibility branch. |
| OAuth (DCR, PKCE, CIMD) | Bearer tokens and custom headers work today. Full authorization needs the confused-deputy mitigations done properly, not quickly. |
| MRTR / elicitation | Server-initiated input via input_required and a request retry. Needs a human-in-the-loop surface, which is prism-harness's job. |
subscriptions/listen |
A long-lived stream does not fit a request that boots, serves and dies. |
| Prompts and resources | Reachable now through ->client(); not yet wrapped in the trust layer. |
| Server direction | Exposing your tools over MCP. laravel/mcp does this well and Prism core already bridges to it via Tool::make(). |
Relationship to other packages
laravel/mcp builds MCP servers — exposing your application's
capabilities. Prism core already bridges to it: Tool::make() accepts a
Laravel\Mcp\Server\Tool. As of v1.0.0-beta.1 it also ships a client, and it
is a good one. This package is the other half of the problem: consuming a server
you do not own, which is where the trust boundary lives. Use both.
They do not yet talk to each other over the wire, and that surprises people. This client speaks
2026-07-28only.laravel/mcpv1.0.0-beta.1implements the previous revision —initialize, then a session — so pointing this client at a server built on it fails, by design rather than by accident.A client and a server in one estate read as interoperable, so it is said here rather than left to be discovered: a
laravel/mcpserver is reachable from this client oncelaravel/mcpships2026-07-28, and not before. In the meantime, tools you own are reachable from a Prism agent natively throughTool::make(), which skips the transport entirely.The failure names the cause. A server answering
-32601forserver/discover— which the revision makes a server MUST — is reported as a probable revision mismatch, not as a missing method.
prism-php/relay was the previous answer to consuming MCP servers from
Prism. It is superseded — this package declares replace — and the reasons are
worth stating, because they are the design brief:
- It hardcodes protocol
2024-11-05and has not been released since 2026-03-20, leaving it two eras behind, on a transport now formally deprecated. - It guesses a tool's calling convention from its parameter names
(
isUrlBasedTool,hasScriptParameter), which silently mis-calls anything it guesses wrong about. - It has no trust boundary of any kind.
Two things it got right are carried forward deliberately: caching the tool list, and namespacing tool names per server.
prism core is untouched. It is a provider API shuttle and stays one — see
the Prism boundary. Core's
PrismServer is an OpenAI-compatible endpoint: a different protocol solving a
different problem, and it stays where it is.
Contributing
composer test # Pest composer types # PHPStan level 6 composer format # Pint
Every guard in this package has been broken on purpose and watched go red. A green assertion nobody has seen fail is a hypothesis, not a test — if you add a check, add the mutant that proves it works.
That includes the shipped configuration. A test that writes the config it reads
is a closed loop and proves nothing about what a consumer gets; ShippedConfigTest
reads the published file and proves the values in it reach behaviour.
License
MIT.