heihallo / mcp-kit
Staff tooling over MCP for Laravel apps: ability catalogue, per-person tokens, access and audit middleware, preview/confirm writes, ground rules, per-user assistant memory and onboarding.
Requires
- php: ^8.3
- illuminate/console: ^12.0|^13.0
- illuminate/contracts: ^12.0|^13.0
- illuminate/database: ^12.0|^13.0
- illuminate/http: ^12.0|^13.0
- illuminate/routing: ^12.0|^13.0
- illuminate/support: ^12.0|^13.0
- illuminate/view: ^12.0|^13.0
- laravel/mcp: ^0.9
- laravel/sanctum: ^4.0
- spatie/laravel-activitylog: ^4.9|^5.0
Requires (Dev)
- laravel/pint: ^1.20
- livewire/livewire: ^4.0
- orchestra/testbench: ^10.0|^11.0
- pestphp/pest: ^3.0|^4.0
- spatie/laravel-permission: ^6.0|^8.0
Suggests
- livewire/flux: The optional UI renders with Flux components; the tokens page also uses Flux Pro's table and tabs.
- livewire/livewire: Required by the optional tokens page and the assistant-memory component (mcp-kit.ui.*).
- spatie/laravel-permission: Lets the kit read roles and permissions from spatie (SpatiePermissionChecker, SpatieRolesDescriber).
Provides
None
Conflicts
None
Replaces
None
- dev-main
- v1.24.0
- v1.23.0
- v1.22.1
- v1.22.0
- v1.21.4
- v1.21.3
- v1.21.2
- v1.21.1
- v1.21.0
- v1.20.0
- v1.19.0
- v1.18.1
- v1.18.0
- v1.17.0
- v1.16.0
- v1.15.0
- v1.14.3
- v1.14.2
- v1.14.1
- v1.14.0
- v1.13.0
- v1.12.2
- v1.12.1
- v1.12.0
- v1.11.7
- v1.11.6
- v1.11.5
- v1.11.4
- v1.11.3
- v1.11.2
- v1.11.1
- v1.11.0
- v1.10.0
- v1.9.2
- v1.9.1
- v1.9.0
- v1.8.0
- v1.7.0
- v1.6.0
- v1.5.0
- v1.4.2
- v1.4.1
- v1.4.0
- v1.3.1
- v1.3.0
- v1.2.1
- v1.2.0
- v1.1.2
- v1.1.1
- v1.1.0
- v1.0.0
- v0.3.8
- v0.3.7
- v0.3.6
- v0.3.5
- v0.3.4
- v0.3.3
- v0.3.2
- v0.3.1
- v0.3.0
- v0.2.0
- v0.1.0
This package is auto-updated.
Last update: 2026-10-01 07:47:19 UTC
README
Staff tooling over MCP for Laravel apps, as a package: the ability catalogue, per-person tokens, access and audit middleware, preview/confirm writes, ground rules, a me resource with per-user assistant memory, saved playbooks, gap reports, usage learning, and a short optional onboarding. Every app that exposes tools to Claude, Codex or another assistant needs the same foundation; this is it, once.
Requires PHP 8.3+, Laravel 12 or 13, laravel/mcp 0.9, Sanctum 4 and spatie/laravel-activitylog 4.9 or 5. Postgres first; other databases work for everything but the JSONB memory column.
What you get
- One catalogue of token abilities (
'shop:orders:read' => ['Search orders', 'orders', 'shop']) that the tools, the token command, the tokens page and the tests all read. A token never out-ranks its owner: the permission behind each ability is re-checked on every call. - Guarded servers.
mcp-kit.serversregisters each server withauth:sanctum, a per-token throttle,EnsureMcpAccess(blocked owners, inactive service clients, browser sessions, tokens without an ability for this server) andAuditMcpCall. AnMcp::webroute that bypasses the kit makes the app refuse to boot. - One write shape.
previewOrExecute()previews withoutconfirm=trueand executes with it. Every confirmed write and every tool call becomes anactivity_logrow in themcplog, in the person's name, with the sanitised arguments and acall_idshared by the domain rows written during the call. {scheme}://metells the assistant who it is talking to: role, team, what this token may do, how the person usually works, what was remembered. A hint, not a mode.remember_about_mesaves what the person confirms, in a JSON column onusers.getting_startedasks only for what the app does not already know, at most three questions, and is offered once.- What the tools are used for.
working_onrecords why a piece of work was started and whether it succeeded, and stamps every call in between — so{scheme}://usagecan show what people actually come here to do and where it falls short. Off by default;metells the person it is on. - Gap reports.
report_gapfiles what someone needed and the app could not do — deduplicated, so the same gap gathers weight rather than duplicating, and routed by your own listener onGapReported.{scheme}://gapslists what is open. - Playbooks.
save_playbookkeeps a way of working the person wants back, and every one they saved is offered as an MCP prompt — a slash command in Claude Code. Scope one to certain servers or abilities; privileged staff may share one with everybody.{scheme}://playbookslists them. - Hints when an assistant hits a wall.
neighbourssays which sibling connection owns what this app does not, in every server's instructions and in the gap-report preview;hints.instead_ofnames the one-call tool when the same tool is called over and over. See Where the road continues. - Commands:
mcp:install,mcp:token,mcp:client-token,mcp:docs,mcp:inventory,mcp:audit-tokens,mcp-kit:prune. - Tests for free.
Guards::all()gives an app the ability, schema, access, inventory, docs, preset, token-command, ground-rules andmechecks in one line.
Install
composer require heihallo/mcp-kit php artisan mcp:install php artisan migrate
mcp:install publishes config/mcp-kit.php, writes a server class under app/Mcp/Servers, a ground-rules intro view, tests/Feature/Mcp/KitGuardsTest.php with its inventory snapshot, and the docs page. It is safe to run again. Add --scan to have it read the abilities your existing tools already check and add catalogue entries for them.
Then, in config/mcp-kit.php:
'scheme' => 'shop', 'servers' => [ 'shop' => [ 'class' => App\Mcp\Servers\ShopServer::class, 'path' => '/mcp/shop', 'label' => 'Shop', 'wildcard' => 'shop:*', 'client_name' => 'shop', 'requires_staff' => true, ], ], 'catalogue' => [ 'abilities' => [ 'shop:orders:read' => ['Search and view orders', 'orders', 'shop'], 'shop:orders:write' => ['Change order status, refund', 'orders', 'shop'], 'shop:customers:read' => ['Search customers', 'customers', 'shop'], ], 'explicit_only' => [], 'service_client_writes' => [], ], 'permission_rules' => [ 'staff_permission' => 'access admin', 'privileged_roles' => ['Owner', 'Dev'], ],
A tool:
use HeiHallo\McpKit\Tools\StaffTool; #[IsReadOnly] class SearchOrdersTool extends StaffTool { protected string $name = 'search_orders'; protected string $description = 'Search orders by number, customer or status.'; protected array $inputSchema = ['type' => 'object', 'properties' => ['query' => ['type' => 'string']]]; public function handle(Request $request): Response { if ($denied = $this->requireAbility($request, 'shop:orders:read')) { return $denied; } return Response::json(['orders' => Order::search($request->get('query'))->map( fn (Order $order) => $this->withAdminUrl(['number' => $order->number, 'status' => $order->status], $order), )]); } }
A write:
return $this->previewOrExecute( $request, ['order' => $order->number, 'from' => $order->status, 'to' => $status], fn () => ['order' => $order->refresh()->only('number', 'status')], 'Change order status', ['subject' => $order], );
Mint a token and connect:
php artisan mcp:token kari@example.com
It prints the token and one claude mcp add … line per server the token reaches.
Overriding behaviour
Every replaceable piece is a contract bound from a config key. Point the key at your own class, or re-bind the contract in your provider. Never edit a package file.
| Contract | Default | Config key | Swap it when |
|---|---|---|---|
PrincipalResolver |
DefaultPrincipalResolver |
principal |
"blocked" means something else (McpKit::blockedUsing() for the simple case) |
PermissionChecker |
GatePermissionChecker; also SpatiePermissionChecker, ModelPermissionChecker |
permissions, permission_rules |
permissions live in spatie, or on your user model |
ServiceClient (model) |
Models\ServiceClient |
models.service_client |
you already have an API-client model; null disables service clients |
Links |
NullLinks; also RouteLinks |
links, links_map |
tool results should carry admin page links |
UserDescriber |
AutoDescriber |
describer, describer_options |
me should mention inboxes, shifts, anything app-specific |
GroundRules |
SectionedGroundRules |
ground_rules.{class,intro,sections,remove} |
add sections, drop one, or render from a database |
AuditWriter |
ActivityLogAuditWriter; also NullAuditWriter |
audit, activity |
calls should also go somewhere else |
ResolvesActivitySource |
NullSourceResolver |
activity.source_resolver (McpKit::resolveSourceUsing()) |
activity rows should name the product |
AbilityCatalogue |
ConfigAbilityCatalogue |
abilities, catalogue |
you prefer PHP constants |
PresetResolver |
ConfigPresetResolver |
presets, token_presets |
other presets ('analyst' => ['grant' => ['reports:read']]) |
TokenPolicy |
DefaultTokenPolicy |
token_policy, tokens |
a different prefix or expiry |
MemoryStore, MemoryPolicy |
ColumnMemoryStore, DefaultMemoryPolicy |
memory |
team leads may view their team's memory |
OnboardingQuestions, SuggestsTasks |
DefaultQuestions, ConfigSuggestions |
onboarding, suggestions |
your own questions, suggestions per ability |
Views: php artisan vendor:publish --tag=mcp-kit-views and edit what you need under resources/views/vendor/mcp-kit. A single partial (the ground-rules intro, the tokens-page examples) can be overridden on its own.
Events at every hook point: TokenMinted, TokenRevoked, AccessDenied, AbilityDenied, ToolCallRecorded, WritePreviewed, WriteConfirmed, MemoryUpdated, OnboardingOffered, OnboardingCompleted, OnboardingDeclined.
Activity log
The kit standardises on spatie/laravel-activitylog and adds three columns to its table: source (the product), channel (web, mcp, api, cli, chat, system) and token_name. They are filled on every row as it is created, only where still null, whatever model the app uses. Tool calls land in the mcp log: event is read, previewed, executed, denied or failed; description is the confirmed action or the tool name; properties carry tool, server, arguments, result, duration, call_id, client. mcp-kit:prune removes rows older than activity.retain_days; schedule it daily.
Optional UI
With Livewire 4 and Flux installed, set mcp-kit.ui.enabled and ui.tokens_page.enabled (or run mcp:install --with-tokens-page). The page, "Connect AI" at settings/connect, is where a person connects an assistant. With mcp-kit.oauth on it opens on signing in with a URL: the name and address to copy, the steps for Claude, Claude Code and Codex, the first message to send, things to ask (tokens/examples, override it with your own), and the sign-ins already made, each with Disconnect. Personal tokens sit behind the second segment, API tokens: mint with a preset filtered by the person's own permissions, list, revoke, and the connect snippets for Claude Code, Claude Desktop, Codex and cURL. With OAuth off the page is the tokens and the examples. What the assistant remembers about the person sits collapsed at the bottom. ui.tokens_page.redirect_from lists old paths that should redirect to the page, and ui.connected_apps_page.redirect_to sends the standalone Connected apps page there too. Apps that mount the page in their own route embed <livewire:mcp-kit.connect /> (mcp-kit.tokens-page still works) and add their own redirect. Each client tab also says where to get the client itself — the download link, the one line that installs it on each platform, the command that proves it is there, and the vendor's own instructions — from Tokens\ClientSetup, so every app on the kit tells staff the same thing. The people minting these tokens were told an assistant could read the CRM; they were not told to install anything, and claude mcp add … is not a first instruction. The table and tabs are Flux Pro components. Add the package views to Tailwind: @source '../../vendor/heihallo/mcp-kit/resources/views';.
ui.usage_page.enabled adds a second page at settings/mcp-usage: what the tools were used for and what people needed and could not get — the browser twin of {scheme}://usage and {scheme}://gaps. It refuses anybody who is not privileged, because it is a record of colleagues' work. A gap is decided from here (planned, built, or turned down) with a line saying why; everybody who reported it reads that line the next time they read {scheme}://me.
List tools
PagesResults (already on StaffTool) gives a list tool offset, direction and a per-tool sort, and puts total and has_more on every reply. Merge PAGING_PROPERTIES into the tool's schema, declare its own sort enum, and call applyPaging($query, $request, [...]) before get(). Without it a capped list is indistinguishable from a complete one, and nothing past the cap can be reached at all.
File uploads
MCP carries JSON, not bytes. With uploads.enabled, the kit registers POST /mcp/uploads behind the same token and access gate as the servers: multipart field file in, handle (up_…) out. A tool takes the handle through AcceptsUploads — merge UPLOAD_PROPERTY into its schema, resolve with stagedUpload(), copy from stagedPath() into the app's real home, record it with uploadConsumed(). Staged files belong to the person (not the token — tokens rotate), are listable with the shared list_uploads tool, and expire after uploads.ttl_days (default 3, MCP_UPLOAD_TTL_DAYS): a loading dock, not a warehouse. mcp-kit:prune sweeps the dock.
An assistant behind a connector (Claude, ChatGPT, Codex with the token in its config) talks MCP through a token it never sees, so it cannot send the bearer header. The shared request_upload tool gives it a signed link to POST /mcp/uploads/link instead, bound to the token that asked and valid for uploads.link_minutes (default 30, MCP_UPLOAD_LINK_MINUTES). Behind the link everything is the same: the access gate, the limits, the handle. Revoking the token kills its links.
Sign in with a URL (OAuth)
Off unless oauth.enabled (MCP_KIT_OAUTH=true). With it on, a person adds the server URL to Claude, ChatGPT or Codex and signs in in the browser — no token to copy. oauth.mode = local makes the app its own authorization server: OAuth 2.1 with public clients only, authorization code with PKCE (S256, required), dynamic client registration restricted to oauth.redirect_hosts and loopback on any port, and a refresh token that rotates. The consent page sits behind oauth.consent_middleware (default web, auth), so signing in is whatever the app already does; ConfirmsFreshLogin sends a person whose login is older than oauth.confirm_minutes through the app's password.confirm step first.
The access token is an ordinary kit token minted through TokenMinter, named under oauth.token_prefix, and lives oauth.access_minutes (60). Every guard treats it like a pasted one: the owner must still hold each permission on every call. The consent screen offers the oauth.preset narrowed to the server the client asked for (the RFC 8707 resource), and never a wildcard or an explicit-only ability. A refresh deletes the previous access token; the old refresh token gets the same new pair back for oauth.refresh_grace_seconds, and after that a replay revokes the whole grant. Task frames and hints are keyed on the grant (Principal::frameKey()), so the hourly rotation does not split a piece of work. Grants, not rotations, go in the audit log.
ui.connected_apps_page.enabled adds settings/connected-apps, where anyone who signed in sees their connections and disconnects one at once. Sign-in tokens never show on the tokens page. mcp-kit:prune ends grants whose refresh token ran out and forgets clients nobody has used in oauth.client_idle_days. The tables are migrated whether or not the feature is on.
An app that signs in through a central auth service has no password to re-confirm. Bind oauth.confirms => SsoConfirmsFreshLogin::class and call SsoConfirmsFreshLogin::stamp($request) in the SSO callback after logging the person in: consent then needs a sign-in at the auth service younger than oauth.confirm_minutes, and sends an older one through oauth.login_route (with oauth.login_parameters, e.g. ['prompt' => 'login'] if the app passes it on) and back. A callback that never stamps gets a plain error, not a redirect loop.
oauth.follow_permissions lets a sign-in grow with the person: an ability they gain later, and that consent would offer them today, joins at the next refresh. Never one they unticked on the consent page (kept on the grant as declined), never one in oauth.unticked. Off, a sign-in stays at exactly what was consented to; lost abilities drop out either way.
Avatars. mcp-kit.avatar (MCP_KIT_AVATAR), or avatar on a server entry, is sent as serverInfo.icons for clients that show one: a URL or a path under public/, square, a 512x512 PNG or an SVG. An alias shows its target's.
mode = delegated (the group auth service as authorization server, see docs/specs/oauth-sign-in.md) is planned and refuses to boot in this version.
One server for staff and customers
A staff server can open named abilities to people who are not staff: open_abilities => ['app:practice:*'] on the server entry. Such a person passes the door when their token holds an opened ability; every other ability stays staff-only in each call and at minting. Add list_granted_only => true and tools/list shows each caller only the tools their token can use. Only the list is filtered — a call to an unlisted tool still gets the refusal that names the ability.
Where the road continues
An assistant that cannot do something here has no way of knowing whether the job is impossible or simply somebody else's. It gives up, works around it, or spends two hundred calls doing by hand what one tool does in one call. Two pieces of config fix that, and both are the app's own.
neighbours names the sibling connections of the same product family:
'neighbours' => [ 'crm' => [ 'label' => 'Acme CRM', 'owns' => 'People and everything around them: customers, signups, invoices', 'tools' => ['search_contacts', 'list_signups'], 'match' => ['customer', 'kunde', 'signup', 'signups', 'invoice', 'invoices', 'faktura*'], 'url' => 'https://crm.example.com/settings/tokens', 'ask' => 'Anything else worth saying about getting in.', ], ],
url is where staff mint their own token for that connection, and every hint ends with it rather than with somebody to wait for. The kit renders the list into every server's instructions as What is not here, matches a refused call's arguments against it — the moment an assistant decides the job is impossible — and matches a gap report against it in the preview — so somebody filing "cannot see a customer's invoices" is told where that lives before anything is filed. Confirming still files it: a wrong guess must never swallow a report.
Matching is whole words at both ends, and nothing is stemmed. A word ending in * matches the compound instead — which is how Norwegian is written, where karakter* is what catches karakterfordelingen.
This is your own organisation and nothing else. The list is read by that organisation's staff and their assistants; one client's app must never mention another's. The kit ships neighbours empty and no default will ever fill it.
hints.instead_of names the shorter road within this app:
'hints' => [ 'instead_of' => [ 'get_thing' => ['use' => 'list_things', 'say' => 'takes a whole list at once', 'after' => 5], ], ],
Past after calls to that tool in one stretch of work (default hints.after, repeated every hints.repeat_every), the kit appends a sentence to the tool's own reply. The call is answered as normal — a nudge, never a refusal. Counted per stretch of work when learning is on and per token otherwise, never across people, and turned off wholesale with hints.enabled.
Strict parameters
An argument a tool does not declare is refused, naming the closest real parameter, rather than silently dropped — a dropped argument makes the tool answer a different question and sound sure about it. People only; service clients are exempt because their calls are code you change deliberately. Turn it off with MCP_STRICT_PARAMETERS=false, and list anything that should never count as unknown in always_allowed_parameters (default confirm). A tool that turns a field away on purpose — it belongs to another service, or has a tool of its own — declares protected array $refusedParameters = ['name' => 'name is owned by auth.afpt and cannot be changed here'], and that reason is what the caller reads.
Testing in your app
// tests/Feature/Mcp/KitGuardsTest.php Guards::all(inventory: __DIR__.'/tool-inventory.json'); // tests/Pest.php Guards::actors( staff: fn () => User::factory()->create(), privileged: fn () => User::factory()->owner()->create(), blocked: fn () => User::factory()->blocked()->create(), serviceClient: fn () => ServiceClient::create(['name' => 'harness', 'slug' => 'harness']), );
Helpers: Testing\Mcp::token(), ::actingWith(), ::listTools(), ::call(), ::readResource(); expectations toBePreview(), toHaveExecuted(), toDenyAbility().
The tool snapshot
tests/Feature/Mcp/tool-inventory.json is the record of what the app deliberately exposes: each server, the tools on it, and the parameters each tool takes.
{
"crm": {
"check_contacts": ["identifiers", "limit"],
"log_outcomes": ["confirm", "follow_up_days", "outcomes", "status"]
}
}
The guard reads it in three passes, in the order the damage runs:
- A tool that is gone or renamed fails first: every client already calling it breaks.
- A tool that kept its name and changed what it takes fails next —
reconcile_subscriptions gained: scope, settle_missing, confirm. A parameter is not a detail of a tool; it is something the tool can now be asked to do, and its name is all a caller has to go on. One flag of that kind was the whole of a near-miss in one of these apps: a reconciliation that would have cancelled the subscriptions it could not match. A parameter that disappears gets its own message, because the clients sending it break either loudly or quietly. - A tool that appeared fails last, named, with the question of whether it belongs in the catalogue at all.
Re-pin with php artisan mcp:inventory (--check in CI). A snapshot pinned by name alone — a list of strings per server — keeps working and is compared by name; running the command once is how an app opts in to parameters.
Read-only mode
MCP_READ_ONLY=true hides every tool not annotated #[IsReadOnly] and makes previewOrExecute() refuse confirm=true.
Developing the package
createdb mcp_kit_testbench composer install vendor/bin/pest
The suite wipes its database on every run and refuses to start against one not named mcp_kit_testbench*.
License
MIT.