MCP: workspace settings, viewer read access, and token access (#241)
* Add workspace MCP settings and token access controls. Ship MCP settings UI, OAuth revoke/list helpers, Passport deploy wiring, and workspace.token:mcp gating so assistants can connect without pulling in welcome/onboarding from the parent epic. Co-authored-by: Cursor <cursoragent@cursor.com> * Type MCP client config shapes instead of string checks. Encode http/config-root on each advanced client and tighten primary client ids so snippet generation does not branch on magic strings. Co-authored-by: Cursor <cursoragent@cursor.com> * Polish MCP settings follow-ups from review. Translate Ukrainian MCP copy, deep-link ChatGPT into connector creation, drop an unused asset and revoke arg, and assert PATs are rejected on the MCP endpoint. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP connected clients, revoke scope, and OAuth consent. List recoverable sessions with live refresh tokens, revoke only PATs, throttle registration alone, and block viewers from authorizing MCP. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify MCP OAuth route throttling to a single middleware group. Co-authored-by: Cursor <cursoragent@cursor.com> * Allow workspace viewers read-only MCP access with web policy writes. Mirror the web app: MCP connects on view + OAuth mcp:use, write tools enforce createPost/update/delete/manageAccounts/manageTeam, and demotion to Viewer keeps grants. Cover role denials, consent, and disconnect. Co-authored-by: Cursor <cursoragent@cursor.com> * Harden MCP tool authz with shared workspace helpers. Route ApiKey tools through AuthorizesMcpTool, fail closed on null user or policy argument, and resolve the current workspace before mutating. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant string casts on validated request data. Enum::from and validated() fields are already strings, so the casts add noise without changing behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Show only the current user's MCP connections in settings. Match API keys privacy: list and disconnect your own OAuth clients, not teammates' across the account. Co-authored-by: Cursor <cursoragent@cursor.com> * Cover LoadWorkspaceFromToken gaps and harden AuthorizesMcpTool tests. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop redundant is_string guard before UpdatePostTool find. Co-authored-by: Cursor <cursoragent@cursor.com> * Refactor AppSidebar to always show MCP link and simplify route middleware definition in ai.php. The MCP link is now consistently displayed regardless of the current workspace state, and the route middleware syntax has been streamlined. * Refresh MCP connected clients with Inertia usePoll. Co-authored-by: Cursor <cursoragent@cursor.com> * Bump laravel/mcp to 0.9.1 and add the TryPost server icon. Requires laravel/boost 2.5 for the Icon attribute; expose images/trypost/icon.png on TryPostServer. Co-authored-by: Cursor <cursoragent@cursor.com> * Drop no-op ReflectionClass import in TryPostServerTest. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f62b4bb4a5
commit
4d8353d758
158 changed files with 8236 additions and 906 deletions
104
.agents/skills/infer-conventions/SKILL.md
Normal file
104
.agents/skills/infer-conventions/SKILL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
---
|
||||||
|
name: infer-conventions
|
||||||
|
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infer Conventions
|
||||||
|
|
||||||
|
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||||
|
|
||||||
|
## Ground Rules (read before you start)
|
||||||
|
|
||||||
|
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||||
|
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||||
|
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||||
|
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||||
|
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||||
|
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||||
|
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||||
|
|
||||||
|
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||||
|
|
||||||
|
### Step 0: Orient
|
||||||
|
|
||||||
|
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||||
|
|
||||||
|
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||||
|
|
||||||
|
### Step 1: Predefined sweep
|
||||||
|
|
||||||
|
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||||
|
|
||||||
|
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||||
|
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||||
|
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||||
|
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||||
|
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||||
|
|
||||||
|
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||||
|
|
||||||
|
### Step 2: Open-ended pass
|
||||||
|
|
||||||
|
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||||
|
|
||||||
|
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||||
|
|
||||||
|
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||||
|
|
||||||
|
### Step 3: Confirm
|
||||||
|
|
||||||
|
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||||
|
|
||||||
|
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||||
|
|
||||||
|
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||||
|
|
||||||
|
### Step 4: Record
|
||||||
|
|
||||||
|
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||||
|
|
||||||
|
Record this:
|
||||||
|
|
||||||
|
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||||
|
|
||||||
|
Not this:
|
||||||
|
|
||||||
|
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||||
|
|
||||||
|
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||||
|
|
||||||
|
### Step 5: Summarize
|
||||||
|
|
||||||
|
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||||
|
|
||||||
|
## Glob mapping
|
||||||
|
|
||||||
|
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||||
|
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||||
|
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||||
|
- Tests: `tests/**`.
|
||||||
|
- Migrations and database: `database/migrations/**`.
|
||||||
|
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||||
|
|
||||||
|
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||||
|
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||||
|
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||||
|
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||||
|
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||||
137
.agents/skills/infer-conventions/references/checklist.md
Normal file
137
.agents/skills/infer-conventions/references/checklist.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Detection Checklist
|
||||||
|
|
||||||
|
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||||
|
|
||||||
|
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Validation & HTTP input
|
||||||
|
|
||||||
|
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||||
|
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||||
|
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||||
|
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||||
|
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||||
|
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||||
|
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||||
|
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||||
|
|
||||||
|
## B. Controllers & routing
|
||||||
|
|
||||||
|
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||||
|
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||||
|
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||||
|
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||||
|
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||||
|
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||||
|
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||||
|
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||||
|
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||||
|
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||||
|
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||||
|
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||||
|
|
||||||
|
## C. Authorization
|
||||||
|
|
||||||
|
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||||
|
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||||
|
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||||
|
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||||
|
|
||||||
|
## D. Eloquent & models
|
||||||
|
|
||||||
|
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||||
|
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||||
|
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||||
|
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||||
|
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||||
|
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||||
|
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||||
|
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||||
|
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||||
|
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||||
|
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||||
|
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||||
|
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||||
|
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||||
|
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||||
|
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||||
|
|
||||||
|
## E. Architecture & organization
|
||||||
|
|
||||||
|
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||||
|
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||||
|
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||||
|
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||||
|
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||||
|
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||||
|
24. Decoupling: events + listeners vs direct service calls.
|
||||||
|
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||||
|
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||||
|
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||||
|
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||||
|
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||||
|
27. Enums: backed vs pure; case naming; where they live.
|
||||||
|
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||||
|
|
||||||
|
## F. Frontend & views
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the items below apply.
|
||||||
|
|
||||||
|
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||||
|
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||||
|
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||||
|
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||||
|
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||||
|
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||||
|
|
||||||
|
## G. Database & migrations
|
||||||
|
|
||||||
|
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||||
|
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||||
|
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||||
|
- Hint: grep `function down` vs the migration count.
|
||||||
|
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||||
|
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||||
|
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||||
|
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||||
|
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||||
|
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||||
|
|
||||||
|
## H. Testing
|
||||||
|
|
||||||
|
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||||
|
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||||
|
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||||
|
- Hint: grep those trait names in `tests/`.
|
||||||
|
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||||
|
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||||
|
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||||
|
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||||
|
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||||
|
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||||
|
|
||||||
|
## I. Responses & API resources
|
||||||
|
|
||||||
|
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||||
|
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||||
|
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||||
|
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||||
|
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||||
|
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||||
|
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||||
|
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||||
|
|
||||||
|
## J. Strings, collections & dates
|
||||||
|
|
||||||
|
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||||
|
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||||
|
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||||
|
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||||
|
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||||
|
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||||
|
|
@ -8,183 +8,52 @@
|
||||||
|
|
||||||
# Laravel Best Practices
|
# Laravel Best Practices
|
||||||
|
|
||||||
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||||
|
|
||||||
## Consistency First
|
## Consistency First
|
||||||
|
|
||||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||||
|
|
||||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### 1. Database Performance → `rules/db-performance.md`
|
|
||||||
|
|
||||||
- Eager load with `with()` to prevent N+1 queries
|
|
||||||
- Enable `Model::preventLazyLoading()` in development
|
|
||||||
- Select only needed columns, avoid `SELECT *`
|
|
||||||
- `chunk()` / `chunkById()` for large datasets
|
|
||||||
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
|
||||||
- `withCount()` instead of loading relations to count
|
|
||||||
- `cursor()` for memory-efficient read-only iteration
|
|
||||||
- Never query in Blade templates
|
|
||||||
|
|
||||||
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
|
||||||
|
|
||||||
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
|
||||||
- Dynamic relationships via subquery FK + `belongsTo`
|
|
||||||
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
|
||||||
- `setRelation()` to prevent circular N+1 queries
|
|
||||||
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
|
||||||
- Two simple queries can beat one complex query
|
|
||||||
- Compound indexes matching `orderBy` column order
|
|
||||||
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
|
||||||
|
|
||||||
### 3. Security → `rules/security.md`
|
|
||||||
|
|
||||||
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
|
||||||
- No raw SQL with user input — use Eloquent or query builder
|
|
||||||
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
|
||||||
- Validate MIME type, extension, and size for file uploads
|
|
||||||
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
|
||||||
|
|
||||||
### 4. Caching → `rules/caching.md`
|
|
||||||
|
|
||||||
- `Cache::remember()` over manual get/put
|
|
||||||
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
|
||||||
- `Cache::memo()` to avoid redundant cache hits within a request
|
|
||||||
- Cache tags to invalidate related groups
|
|
||||||
- `Cache::add()` for atomic conditional writes
|
|
||||||
- `once()` to memoize per-request or per-object lifetime
|
|
||||||
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
|
||||||
- Failover cache stores in production
|
|
||||||
|
|
||||||
### 5. Eloquent Patterns → `rules/eloquent.md`
|
|
||||||
|
|
||||||
- Correct relationship types with return type hints
|
|
||||||
- Local scopes for reusable query constraints
|
|
||||||
- Global scopes sparingly — document their existence
|
|
||||||
- Attribute casts in the `casts()` method
|
|
||||||
- Cast date columns, use Carbon instances in templates
|
|
||||||
- `whereBelongsTo($model)` for cleaner queries
|
|
||||||
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
|
||||||
|
|
||||||
### 6. Validation & Forms → `rules/validation.md`
|
|
||||||
|
|
||||||
- Form Request classes, not inline validation
|
|
||||||
- Array notation `['required', 'email']` for new code; follow existing convention
|
|
||||||
- `$request->validated()` only — never `$request->all()`
|
|
||||||
- `Rule::when()` for conditional validation
|
|
||||||
- `after()` instead of `withValidator()`
|
|
||||||
|
|
||||||
### 7. Configuration → `rules/config.md`
|
|
||||||
|
|
||||||
- `env()` only inside config files
|
|
||||||
- `App::environment()` or `app()->isProduction()`
|
|
||||||
- Config, lang files, and constants over hardcoded text
|
|
||||||
|
|
||||||
### 8. Testing Patterns → `rules/testing.md`
|
|
||||||
|
|
||||||
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
|
||||||
- `assertModelExists()` over raw `assertDatabaseHas()`
|
|
||||||
- Factory states and sequences over manual overrides
|
|
||||||
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
|
||||||
- `recycle()` to share relationship instances across factories
|
|
||||||
|
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
|
||||||
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
|
||||||
- Horizon for complex multi-queue scenarios
|
|
||||||
|
|
||||||
### 10. Routing & Controllers → `rules/routing.md`
|
|
||||||
|
|
||||||
- Implicit route model binding
|
|
||||||
- Scoped bindings for nested resources
|
|
||||||
- `Route::resource()` or `apiResource()`
|
|
||||||
- Methods under 10 lines — extract to actions/services
|
|
||||||
- Type-hint Form Requests for auto-validation
|
|
||||||
|
|
||||||
### 11. HTTP Client → `rules/http-client.md`
|
|
||||||
|
|
||||||
- Explicit `timeout` and `connectTimeout` on every request
|
|
||||||
- `retry()` with exponential backoff for external APIs
|
|
||||||
- Check response status or use `throw()`
|
|
||||||
- `Http::pool()` for concurrent independent requests
|
|
||||||
- `Http::fake()` and `preventStrayRequests()` in tests
|
|
||||||
|
|
||||||
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
|
||||||
|
|
||||||
- Event discovery over manual registration; `event:cache` in production
|
|
||||||
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
|
||||||
- Queue notifications and mailables with `ShouldQueue`
|
|
||||||
- On-demand notifications for non-user recipients
|
|
||||||
- `HasLocalePreference` on notifiable models
|
|
||||||
- `assertQueued()` not `assertSent()` for queued mailables
|
|
||||||
- Markdown mailables for transactional emails
|
|
||||||
|
|
||||||
### 13. Error Handling → `rules/error-handling.md`
|
|
||||||
|
|
||||||
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
|
||||||
- `ShouldntReport` for exceptions that should never log
|
|
||||||
- Throttle high-volume exceptions to protect log sinks
|
|
||||||
- `dontReportDuplicates()` for multi-catch scenarios
|
|
||||||
- Force JSON rendering for API routes
|
|
||||||
- Structured context via `context()` on exception classes
|
|
||||||
|
|
||||||
### 14. Task Scheduling → `rules/scheduling.md`
|
|
||||||
|
|
||||||
- `withoutOverlapping()` on variable-duration tasks
|
|
||||||
- `onOneServer()` on multi-server deployments
|
|
||||||
- `runInBackground()` for concurrent long tasks
|
|
||||||
- `environments()` to restrict to appropriate environments
|
|
||||||
- `takeUntilTimeout()` for time-bounded processing
|
|
||||||
- Schedule groups for shared configuration
|
|
||||||
|
|
||||||
### 15. Architecture → `rules/architecture.md`
|
|
||||||
|
|
||||||
- Single-purpose Action classes; dependency injection over `app()` helper
|
|
||||||
- Prefer official Laravel packages and follow conventions, don't override defaults
|
|
||||||
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
|
||||||
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
|
||||||
|
|
||||||
### 16. Migrations → `rules/migrations.md`
|
|
||||||
|
|
||||||
- Generate migrations with `php artisan make:migration`
|
|
||||||
- `constrained()` for foreign keys
|
|
||||||
- Never modify migrations that have run in production
|
|
||||||
- Add indexes in the migration, not as an afterthought
|
|
||||||
- Mirror column defaults in model `$attributes`
|
|
||||||
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
|
||||||
- One concern per migration — never mix DDL and DML
|
|
||||||
|
|
||||||
### 17. Collections → `rules/collections.md`
|
|
||||||
|
|
||||||
- Higher-order messages for simple collection operations
|
|
||||||
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
|
||||||
- `lazyById()` when updating records while iterating
|
|
||||||
- `toQuery()` for bulk operations on collections
|
|
||||||
|
|
||||||
### 18. Blade & Views → `rules/blade-views.md`
|
|
||||||
|
|
||||||
- `$attributes->merge()` in component templates
|
|
||||||
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
|
||||||
- View Composers for shared view data
|
|
||||||
- `@aware` for deeply nested component props
|
|
||||||
|
|
||||||
### 19. Conventions & Style → `rules/style.md`
|
|
||||||
|
|
||||||
- Follow Laravel naming conventions for all entities
|
|
||||||
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
|
||||||
- No JS/CSS in Blade, no HTML in PHP classes
|
|
||||||
- Code should be readable; comments only for config files
|
|
||||||
|
|
||||||
## How to Apply
|
## How to Apply
|
||||||
|
|
||||||
Always use a sub-agent to read rule files and explore this skill's content.
|
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
||||||
|
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
||||||
|
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
||||||
|
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
||||||
|
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
||||||
|
6. Re-read the diff against every mapped rule before finishing.
|
||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
## Rule Index
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
Cross-cutting changes often need more than one rule file.
|
||||||
|
|
||||||
|
| Concern | Read |
|
||||||
|
| --- | --- |
|
||||||
|
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
||||||
|
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
||||||
|
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
||||||
|
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
||||||
|
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
||||||
|
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
||||||
|
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
||||||
|
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
||||||
|
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
||||||
|
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
||||||
|
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
||||||
|
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
||||||
|
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
||||||
|
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
||||||
|
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
||||||
|
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
||||||
|
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
||||||
|
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
||||||
|
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||||
|
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
||||||
|
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
||||||
|
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ ## Single-Purpose Action Classes
|
||||||
{
|
{
|
||||||
public function __construct(private InventoryService $inventory) {}
|
public function __construct(private InventoryService $inventory) {}
|
||||||
|
|
||||||
public function execute(array $data): Order
|
public function handle(array $data): Order
|
||||||
{
|
{
|
||||||
$order = Order::create($data);
|
$order = Order::create($data);
|
||||||
$this->inventory->reserve($order);
|
$this->inventory->reserve($order);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ ## Use Local Scopes for Reusable Queries
|
||||||
|
|
||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
public function scopeActive(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function active(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,8 @@ ## Apply Global Scopes Sparingly
|
||||||
|
|
||||||
Correct (local scope you opt into):
|
Correct (local scope you opt into):
|
||||||
```php
|
```php
|
||||||
public function scopePublished(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function published(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('published', true);
|
return $query->where('published', true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ ## Use Laravel String & Array Helpers
|
||||||
// Incorrect
|
// Incorrect
|
||||||
$slug = strtolower(str_replace(' ', '-', $title));
|
$slug = strtolower(str_replace(' ', '-', $title));
|
||||||
$short = substr($text, 0, 100) . '...';
|
$short = substr($text, 0, 100) . '...';
|
||||||
$class = substr(strrchr('App\Models\User', '\'), 1);
|
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
||||||
|
|
||||||
// Correct
|
// Correct
|
||||||
$slug = Str::slug($title);
|
$slug = Str::slug($title);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: mcp-development
|
name: mcp-development
|
||||||
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
description: "Use this skill for Laravel MCP development. Trigger when creating or editing MCP tools, resources, prompts, servers, or UI apps in Laravel projects. Covers: artisan make:mcp-* generators, routes/ai.php, Tool/Resource/Prompt/AppResource classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, MCP debugging, MCP UI apps, the x-mcp::app Blade component, createMcpApp(), default AppResource handle() auto-infers view from class name, Response::view(), AppMeta/Csp/Permissions/appMeta() configuration, #[RendersApp] attribute, Library enum for CDN libraries (Tailwind, Alpine), and host theming via CSS variables. Use this whenever the user mentions MCP apps, MCP UI, interactive MCP resources, styling MCP apps with Tailwind or Alpine, or building visual interfaces for AI agents."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -12,6 +12,8 @@ ## Documentation
|
||||||
|
|
||||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
For MCP UI apps (interactive HTML resources), read `references/app.md` — it covers the full architecture, host theming CSS variables, tool-to-UI linking patterns, library scripts (Tailwind, Alpine via `Library`), and real-world examples.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
Register MCP servers in `routes/ai.php`:
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
@ -25,8 +27,6 @@ ## Basic Usage
|
||||||
|
|
||||||
### Creating MCP Primitives
|
### Creating MCP Primitives
|
||||||
|
|
||||||
Create MCP tools, resources, prompts, and servers using artisan commands:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan make:mcp-tool ToolName # Create a tool
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
|
@ -36,6 +36,8 @@ ### Creating MCP Primitives
|
||||||
|
|
||||||
php artisan make:mcp-server ServerName # Create a server
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
php artisan make:mcp-app-resource DashboardApp # Create a UI app (2 files)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
@ -44,23 +46,33 @@ ### Tools
|
||||||
|
|
||||||
<!-- MCP Tool Example -->
|
<!-- MCP Tool Example -->
|
||||||
```php
|
```php
|
||||||
|
use Illuminate\Json\Schema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\Server\Tool;
|
use Laravel\Mcp\Server\Tool;
|
||||||
use Laravel\Mcp\Server\Request;
|
|
||||||
use Laravel\Mcp\Server\Response;
|
|
||||||
|
|
||||||
class MyTool extends Tool
|
class MyTool extends Tool
|
||||||
{
|
{
|
||||||
|
protected string $description = 'Describe what this tool does';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $schema->string()->description('The name parameter')->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(Request $request): Response
|
public function handle(Request $request): Response
|
||||||
{
|
{
|
||||||
return new Response(['result' => 'success']);
|
$request->validate(['name' => 'required|string']);
|
||||||
|
|
||||||
|
return Response::text('Hello, '.$request->get('name'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Registering Primitives in a Server
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
|
||||||
|
|
||||||
<!-- Register Primitives in MCP Server -->
|
<!-- Register Primitives in MCP Server -->
|
||||||
```php
|
```php
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
|
|
@ -81,6 +93,10 @@ ### Registering Primitives in a Server
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MCP UI Apps
|
||||||
|
|
||||||
|
For MCP UI apps, read `references/app.md` — it covers quick start examples, full architecture, AppMeta/Csp/Permissions, `#[RendersApp]` tool linking, library scripts (Tailwind/Alpine via `Library`), host theming CSS variables, and real-world patterns.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
1. Check `routes/ai.php` for proper registration
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
|
@ -92,5 +108,5 @@ ## Common Pitfalls
|
||||||
- Using HTTPS locally with Node-based MCP clients
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
- Not using `search-docs` for the latest MCP documentation
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
- Not registering MCP server routes in `routes/ai.php`
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically
|
||||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
|
||||||
940
.agents/skills/mcp-development/references/app.md
Normal file
940
.agents/skills/mcp-development/references/app.md
Normal file
|
|
@ -0,0 +1,940 @@
|
||||||
|
# MCP UI Apps Reference
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
`make:mcp-app-resource DashboardApp` generates two files — a PHP registration stub and a Blade view. The entire app lives in the Blade view.
|
||||||
|
|
||||||
|
**PHP class** — renders the Blade view. The view name is auto-inferred from the class name (`mcp.<kebab-class-name>`), so the generated stub needs no changes unless you're passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blade view** — HTML structure + inline JS, everything in one file:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<h1>Dashboard App</h1>
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
`createMcpApp` is a global pre-bundled by the package — no npm install, no imports, no Vite required. It handles connection, error handling, and host theming automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept: Tool + Resource
|
||||||
|
|
||||||
|
Every MCP App is built from two parts linked together:
|
||||||
|
|
||||||
|
- **Tool** — called by the LLM or host. Returns a text/data response and tells the host which UI resource to render via `_meta.ui.resourceUri`.
|
||||||
|
- **AppResource** — serves the self-contained HTML app. The host fetches it after the tool is called and renders it in a sandboxed iframe.
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM calls Tool
|
||||||
|
└─► Tool response includes _meta.ui.resourceUri → "ui://dashboard-app"
|
||||||
|
└─► Host fetches AppResource at that URI
|
||||||
|
└─► Host renders HTML in sandboxed iframe
|
||||||
|
└─► createMcpApp() connects the iframe back to the server
|
||||||
|
└─► UI calls app-only tools to load/refresh data
|
||||||
|
```
|
||||||
|
|
||||||
|
The link is declared once with `#[RendersApp]` on the tool:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, the host handles fetching and rendering the resource automatically — you never reference the URI by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
MCP Apps add interactive UI to the Model Context Protocol. The server returns self-contained HTML with all JS/CSS inlined. The host renders it in a sandboxed iframe. Apps communicate back via `createMcpApp()` — a pre-bundled global implementing the MCP UI PostMessage protocol.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Host (Claude, ChatGPT, VS Code) │
|
||||||
|
│ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ Sandboxed iframe │ │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Your MCP App (HTML/JS/CSS) │ │ │
|
||||||
|
│ │ │ - Rendered by AppResource │ │ │
|
||||||
|
│ │ │ - Single self-contained HTML │ │ │
|
||||||
|
│ │ │ - Themed via host CSS vars │ │ │
|
||||||
|
│ │ └─────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────┘ │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│ MCP Protocol (JSON-RPC)
|
||||||
|
┌──────────────────▼──────────────────────────┐
|
||||||
|
│ Laravel MCP Server │
|
||||||
|
│ - AppResource → self-contained HTML │
|
||||||
|
│ - Tool #[RendersApp] → triggers UI display │
|
||||||
|
│ - resources/read → serves HTML + _meta.ui │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The server automatically advertises `io.modelcontextprotocol/ui` capability when any `AppResource` is registered. The client declares support in `capabilities.extensions["io.modelcontextprotocol/ui"]` during the initialize handshake.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side
|
||||||
|
|
||||||
|
Minimal case — `handle()` renders the Blade view, entire app lives there:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-renders `resources/views/mcp/dashboard-app.blade.php` with `$title` available via `$this->title()`.
|
||||||
|
|
||||||
|
Override `handle()` only when passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class AnalyticsDashboard extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.analytics-dashboard', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
'metrics' => Metric::latest()->take(10)->get(),
|
||||||
|
'totalUsers' => User::count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Response::view($view, $data = [], $mergeData = [])` renders a Blade view and returns it as text.
|
||||||
|
|
||||||
|
`Response::html($path)` reads an HTML file from disk and returns its content. Relative paths resolve via `resource_path()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class StaticApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::html('mcp/static-app.html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppMeta Configuration
|
||||||
|
|
||||||
|
The simplest way to configure UI metadata is via the `#[AppMeta]` attribute directly on your resource class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\AppMeta;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
#[AppMeta(
|
||||||
|
connectDomains: ['https://api.stripe.com'],
|
||||||
|
permissions: [Permission::Camera, Permission::ClipboardWrite],
|
||||||
|
prefersBorder: true,
|
||||||
|
libraries: [Library::Tailwind, Library::Alpine],
|
||||||
|
)]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For dynamic or computed configuration, override `appMeta()` instead:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\AppMeta;
|
||||||
|
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains(config('services.api.domains')))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind)
|
||||||
|
->domain('sandbox.example.com');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permission Enum
|
||||||
|
|
||||||
|
Use the `Permission` enum for type-safe permission configuration:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
Permission::Camera // 'camera'
|
||||||
|
Permission::Microphone // 'microphone'
|
||||||
|
Permission::Geolocation // 'geolocation'
|
||||||
|
Permission::ClipboardWrite // 'clipboardWrite'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Csp
|
||||||
|
|
||||||
|
Controls what external domains the iframe can access:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Csp::make()
|
||||||
|
->connectDomains(['https://api.example.com']) // fetch, XHR, WebSocket origins
|
||||||
|
->resourceDomains(['https://cdn.example.com']) // images, scripts, fonts, media
|
||||||
|
->frameDomains(['https://embed.example.com']) // nested iframe origins
|
||||||
|
->baseUriDomains(['https://base.example.com']); // base URI origins
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
Permissions::make()->allow(Permission::Camera, Permission::ClipboardWrite);
|
||||||
|
|
||||||
|
Permissions::make()
|
||||||
|
->camera()
|
||||||
|
->microphone()
|
||||||
|
->geolocation()
|
||||||
|
->clipboardWrite();
|
||||||
|
```
|
||||||
|
|
||||||
|
Each enabled permission serializes as `"camera": {}` per the MCP spec.
|
||||||
|
|
||||||
|
#### AppMeta
|
||||||
|
|
||||||
|
```php
|
||||||
|
AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains([...]))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine)
|
||||||
|
->domain('sandbox.example.com') // dedicated sandbox origin (OAuth/CORS)
|
||||||
|
->prefersBorder(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
`prefersBorder` defaults to `true`. `toArray()` omits null fields and empty nested objects. Library CDN domains are automatically merged into `csp.resourceDomains`.
|
||||||
|
|
||||||
|
#### domain
|
||||||
|
|
||||||
|
The `domain` field provides a stable origin that external APIs can allowlist for CORS. It is automatically resolved from `config('app.url')` (your `APP_URL` env variable) via `resolvedAppMeta()`, so most apps need no configuration. Override only when a resource needs a different origin:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(domain: 'custom.example.com')]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Library Scripts
|
||||||
|
|
||||||
|
The `libraries` parameter adds pre-configured CDN scripts to the `<head>` of your app. Available libraries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
|
||||||
|
Library::Tailwind // Tailwind CSS CDN + dark mode config
|
||||||
|
Library::Alpine // Alpine.js CDN + x-cloak style
|
||||||
|
```
|
||||||
|
|
||||||
|
When libraries are specified, the package automatically:
|
||||||
|
|
||||||
|
1. Injects the CDN `<script>` tags into the Blade view's `<head>` (after the MCP SDK, before your `<x-slot:head>`)
|
||||||
|
2. Merges each library's CDN domains into `csp.resourceDomains` so the host allows loading them
|
||||||
|
|
||||||
|
Via attribute:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(libraries: [Library::Tailwind])]
|
||||||
|
class StyledApp extends AppResource
|
||||||
|
{
|
||||||
|
// Tailwind is available in the Blade view — no extra setup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Via fluent builder:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## View Layer
|
||||||
|
|
||||||
|
### `<x-mcp::app>` Blade Component
|
||||||
|
|
||||||
|
Renders a complete self-contained HTML document with the MCP SDK inlined. `createMcpApp` is available globally.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props and slots:**
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
| ------------- | ------------- | ---------------------------------------------------- |
|
||||||
|
| `title` | Prop | Sets `<title>`. Optional. |
|
||||||
|
| `head` | Named slot | Injected into `<head>` after the inlined SDK script. |
|
||||||
|
| Default slot | Slot | Body content. |
|
||||||
|
| `$attributes` | Attribute bag | Forwarded to `<body>` (e.g. `class="dark"`). |
|
||||||
|
|
||||||
|
The SDK is loaded from the `mcp.sdk` singleton (registered by `McpServiceProvider`) and inlined directly in a `<script>` tag. Library scripts (Tailwind, Alpine) configured via `#[AppMeta]` are injected after the SDK and before the `head` slot.
|
||||||
|
|
||||||
|
Publish the component: `php artisan vendor:publish --tag=mcp-views`.
|
||||||
|
|
||||||
|
To pass server-side data to JS, embed it as `data-*` attributes:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div id="app" data-users="{{ $users->toJson() }}">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const users = JSON.parse(document.getElementById("app").dataset.users);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client-Side
|
||||||
|
|
||||||
|
This package provides a simple MCP client library to easily work with client interactions.
|
||||||
|
|
||||||
|
### createMcpApp
|
||||||
|
|
||||||
|
Pre-bundled and inlined automatically — no npm install or imports required.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
// app is ready — connection established, theming applied
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### app.callServerTool()
|
||||||
|
|
||||||
|
Accepts an object or positional arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form
|
||||||
|
const result = await app.callServerTool({ name: 'get-analytics', arguments: { dateRange: '7d' } });
|
||||||
|
|
||||||
|
// Positional form
|
||||||
|
const result = await app.callServerTool('get-analytics', { dateRange: '7d' });
|
||||||
|
|
||||||
|
// result structure depends on the server's tool response
|
||||||
|
const text = result.content[0]?.text ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
All tool results share a standard structure:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
| --------- | --------- | ------------------------------------------------------------------------- |
|
||||||
|
| `content` | `Array` | Content items returned by the tool (each has `type` and `text` or `data`) |
|
||||||
|
| `isError` | `boolean` | `true` when the tool returned an error response |
|
||||||
|
|
||||||
|
Always check `result.isError` before consuming `content`. See [Error Handling](#error-handling) for a full example.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
#### app.listResources()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resources = await app.listResources();
|
||||||
|
// or with cursor for pagination
|
||||||
|
const resources = await app.listResources("cursor-value");
|
||||||
|
// or object form
|
||||||
|
const resources = await app.listResources({ cursor: "cursor-value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.readResource()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resource = await app.readResource("ui://my-resource");
|
||||||
|
// or object form
|
||||||
|
const resource = await app.readResource({ uri: "ui://my-resource" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
#### app.sendMessage()
|
||||||
|
|
||||||
|
Send a message to the model (creates a conversation turn):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form with structured content
|
||||||
|
await app.sendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "User submitted the form." }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shorthand — plain string content with optional role (defaults to 'user')
|
||||||
|
await app.sendMessage("User submitted the form.");
|
||||||
|
await app.sendMessage("System event occurred.", "user");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host Context
|
||||||
|
|
||||||
|
#### app.getHostContext()
|
||||||
|
|
||||||
|
Returns the current host context, including theme and style variables:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ctx = app.getHostContext();
|
||||||
|
ctx?.theme; // 'light' | 'dark'
|
||||||
|
ctx?.styles?.variables; // CSS variable map from host
|
||||||
|
ctx?.styles?.css?.fonts; // font CSS from host
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostInfo()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const info = app.getHostInfo();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostCapabilities()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const caps = app.getHostCapabilities();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation & Files
|
||||||
|
|
||||||
|
#### app.openLink()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.openLink("https://example.com");
|
||||||
|
// or object form
|
||||||
|
await app.openLink({ url: "https://example.com" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.downloadFile()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.downloadFile("file contents here");
|
||||||
|
// or object form
|
||||||
|
await app.downloadFile({ contents: "file contents here" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
#### app.requestDisplayMode()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.requestDisplayMode("fullscreen");
|
||||||
|
// or object form
|
||||||
|
await app.requestDisplayMode({ mode: "fullscreen" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.resize() / app.autoResize()
|
||||||
|
|
||||||
|
`resize()` sends a one-time size notification. `autoResize()` uses `ResizeObserver` to continuously notify the host of size changes. It returns a cleanup function that disconnects the observer — useful if you need to stop observing before teardown. The observer is also automatically disconnected on teardown.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stopObserving = app.autoResize();
|
||||||
|
|
||||||
|
// Later, if needed:
|
||||||
|
stopObserving();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context
|
||||||
|
|
||||||
|
#### app.updateModelContext()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.updateModelContext({ key: "value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
#### app.requestTeardown()
|
||||||
|
|
||||||
|
Sends a teardown notification to the host.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.requestTeardown();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
#### app.sendLog()
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Positional form
|
||||||
|
await app.sendLog("info", "Processing started", "my-logger");
|
||||||
|
|
||||||
|
// Object form
|
||||||
|
await app.sendLog({
|
||||||
|
level: "info",
|
||||||
|
data: "Processing started",
|
||||||
|
logger: "my-logger",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handlers
|
||||||
|
|
||||||
|
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInput((params) => {
|
||||||
|
/* tool input received */
|
||||||
|
});
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
/* partial tool input */
|
||||||
|
});
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
/* tool result received */
|
||||||
|
});
|
||||||
|
app.onToolCancelled((params) => {
|
||||||
|
/* tool was cancelled */
|
||||||
|
});
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
/* theme/styles changed */
|
||||||
|
});
|
||||||
|
app.onTeardown(async () => {
|
||||||
|
/* cleanup before teardown */
|
||||||
|
});
|
||||||
|
app.onCallTool(async (params) => {
|
||||||
|
/* host requests tool call */
|
||||||
|
});
|
||||||
|
app.onListTools(async (params) => {
|
||||||
|
/* host requests tool list */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host Theming
|
||||||
|
|
||||||
|
`createMcpApp` automatically applies host theming on connect and on context change:
|
||||||
|
|
||||||
|
- Sets `data-theme` attribute and `color-scheme` on `<html>`
|
||||||
|
- Applies CSS variables from `hostContext.styles.variables` to `:root`
|
||||||
|
- Injects font CSS from `hostContext.styles.css.fonts` into a `<style>` tag
|
||||||
|
|
||||||
|
The specific CSS variables available depend on the host. Always provide fallback values — use `light-dark()` for theme-aware defaults:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--color-background-primary: light-dark(#ffffff, #171717);
|
||||||
|
--color-text-primary: light-dark(#171717, #fafafa);
|
||||||
|
--color-text-secondary: light-dark(#525252, #a3a3a3);
|
||||||
|
--color-border-primary: light-dark(#e5e5e5, #404040);
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool-to-UI Linking
|
||||||
|
|
||||||
|
### #[RendersApp] Attribute
|
||||||
|
|
||||||
|
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\RendersApp;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Visibility;
|
||||||
|
|
||||||
|
// Both model and app can call this tool (default)
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool { ... }
|
||||||
|
|
||||||
|
// Only the app can call this tool (private to the UI)
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class RefreshDashboardData extends Tool { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visibility:**
|
||||||
|
|
||||||
|
The `Visibility` enum (`Laravel\Mcp\Server\Ui\Enums\Visibility`) has two cases: `Model` and `App`. The default is `[Visibility::Model, Visibility::App]`.
|
||||||
|
|
||||||
|
| Visibility | Model | App | Use case |
|
||||||
|
| -------------------------------------- | ----- | --- | ------------------------------------------------------ |
|
||||||
|
| `[Visibility::Model, Visibility::App]` | Yes | Yes | Primary tools that trigger UI display |
|
||||||
|
| `[Visibility::App]` | No | Yes | Backend actions the UI calls (refresh, save, paginate) |
|
||||||
|
| `[Visibility::Model]` | Yes | No | Model-only tools linked to a UI |
|
||||||
|
|
||||||
|
### Primary + Private Pattern
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetDashboardMetrics extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json(Metric::latest()->take(50)->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
it('returns html content', function () {
|
||||||
|
MyServer::readResource(DashboardApp::class)
|
||||||
|
->assertSee('<div id="app">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct mime type and uri scheme', function () {
|
||||||
|
$resource = new DashboardApp;
|
||||||
|
$data = $resource->toArray();
|
||||||
|
|
||||||
|
expect($data['mimeType'])->toBe('text/html;profile=mcp-app')
|
||||||
|
->and($data['_meta']['ui'])->toBeArray()
|
||||||
|
->and($resource->uri())->toStartWith('ui://');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures ui meta correctly', function () {
|
||||||
|
$meta = (new DashboardApp)->resolvedAppMeta();
|
||||||
|
|
||||||
|
expect($meta['csp']['connectDomains'])->toContain('https://api.example.com')
|
||||||
|
->and($meta['permissions'])->toHaveKey('clipboardWrite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes ui metadata in tool listing', function () {
|
||||||
|
MyServer::listTools()->assertSee('show-dashboard');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Real-time Polling
|
||||||
|
|
||||||
|
Use app-only tools to fetch fresh data at regular intervals from the UI:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: MonitorApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetMonitorData extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch latest monitor metrics';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json([
|
||||||
|
'cpu' => sys_getloadavg()[0],
|
||||||
|
'memory' => memory_get_usage(true),
|
||||||
|
'timestamp' => now()->toISOString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function poll() {
|
||||||
|
const result = await app.callServerTool('get-monitor-data');
|
||||||
|
const data = JSON.parse(result.content[0]?.text ?? '{}');
|
||||||
|
document.getElementById('cpu').textContent = data.cpu;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(poll, 2000);
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chunked Data Loading
|
||||||
|
|
||||||
|
For large datasets, implement pagination via app-only tools:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: LogViewerApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetLogChunk extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch a chunk of log entries';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'offset' => $schema->integer()->description('Byte offset to start from')->required(),
|
||||||
|
'limit' => $schema->integer()->description('Max bytes to return'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['offset' => 'required|integer', 'limit' => 'integer']);
|
||||||
|
|
||||||
|
$offset = $request->get('offset');
|
||||||
|
$limit = $request->get('limit', 500_000);
|
||||||
|
$content = Storage::get('logs/app.log');
|
||||||
|
$chunk = substr($content, $offset, $limit);
|
||||||
|
|
||||||
|
return Response::json([
|
||||||
|
'data' => $chunk,
|
||||||
|
'offset' => $offset,
|
||||||
|
'totalBytes' => strlen($content),
|
||||||
|
'hasMore' => ($offset + $limit) < strlen($content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Resource Serving
|
||||||
|
|
||||||
|
Deliver images and binary content through MCP resources using `Response::blob()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: GalleryApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetImage extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch an image by ID';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['id' => 'required|integer']);
|
||||||
|
|
||||||
|
$image = Image::findOrFail($request->get('id'));
|
||||||
|
$data = base64_encode(Storage::get($image->path));
|
||||||
|
|
||||||
|
return Response::blob($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the client, convert the base64 blob to a data URI for rendering:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await app.callServerTool('get-image', { id: 42 });
|
||||||
|
const blob = result.content[0];
|
||||||
|
img.src = `data:${blob.mimeType};base64,${blob.data}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Argument Previews
|
||||||
|
|
||||||
|
Use `onToolInputPartial` to show previews as the model streams tool arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
try {
|
||||||
|
const partial = JSON.parse(params.arguments);
|
||||||
|
if (partial.query) {
|
||||||
|
document.getElementById("preview").textContent = partial.query;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// partial JSON — ignore until parseable
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
const data = JSON.parse(params.result.content[0]?.text ?? "{}");
|
||||||
|
renderResults(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View State Persistence
|
||||||
|
|
||||||
|
Use `localStorage` to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const STATE_KEY = "dashboard-view-state";
|
||||||
|
|
||||||
|
// Restore from localStorage
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
||||||
|
if (saved.activeTab) selectTab(saved.activeTab);
|
||||||
|
|
||||||
|
// Save on interaction
|
||||||
|
function saveState(state) {
|
||||||
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For durable state, persist server-side
|
||||||
|
async function saveServerState(state) {
|
||||||
|
await app.callServerTool('save-dashboard-state', { state: JSON.stringify(state) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fullscreen Toggling
|
||||||
|
|
||||||
|
Switch between inline and fullscreen display modes and react to mode changes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById("expand-btn").addEventListener("click", () => {
|
||||||
|
app.requestDisplayMode("fullscreen");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
"fullscreen",
|
||||||
|
ctx.displayMode === "fullscreen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context Updates
|
||||||
|
|
||||||
|
Keep the model informed about what the user is viewing so it can provide relevant assistance:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function notifyContext(view, detail) {
|
||||||
|
await app.updateModelContext({
|
||||||
|
currentView: view,
|
||||||
|
detail: detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify on tab change
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
notifyContext(tab.dataset.view, { filters: getActiveFilters() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// For large payloads, follow up with sendMessage
|
||||||
|
await app.updateModelContext({ currentView: "report", rows: 5000 });
|
||||||
|
await app.sendMessage("The user is viewing a report with 5000 rows.");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause Offscreen Views
|
||||||
|
|
||||||
|
Conserve resources by pausing animations and polling when the view is not visible:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (!pollInterval) {
|
||||||
|
pollInterval = setInterval(fetchData, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
entry.isIntersecting ? startPolling() : stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement);
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Return `Response::error()` from tools and use `updateModelContext()` to signal degraded state:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class ProcessData extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['input' => 'required|string']);
|
||||||
|
|
||||||
|
if (strlen($request->get('input')) > 10_000) {
|
||||||
|
return Response::error('Input exceeds 10KB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(process($request->get('input')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const result = await app.callServerTool('process-data', { input: value });
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
document.getElementById("error").textContent =
|
||||||
|
result.content[0]?.text ?? "Unknown error";
|
||||||
|
await app.updateModelContext({
|
||||||
|
state: "error",
|
||||||
|
message: result.content[0]?.text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOutput(JSON.parse(result.content[0]?.text ?? "{}"));
|
||||||
|
});
|
||||||
|
```
|
||||||
104
.claude/skills/infer-conventions/SKILL.md
Normal file
104
.claude/skills/infer-conventions/SKILL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
---
|
||||||
|
name: infer-conventions
|
||||||
|
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infer Conventions
|
||||||
|
|
||||||
|
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||||
|
|
||||||
|
## Ground Rules (read before you start)
|
||||||
|
|
||||||
|
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||||
|
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||||
|
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||||
|
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||||
|
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||||
|
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||||
|
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||||
|
|
||||||
|
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||||
|
|
||||||
|
### Step 0: Orient
|
||||||
|
|
||||||
|
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||||
|
|
||||||
|
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||||
|
|
||||||
|
### Step 1: Predefined sweep
|
||||||
|
|
||||||
|
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||||
|
|
||||||
|
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||||
|
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||||
|
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||||
|
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||||
|
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||||
|
|
||||||
|
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||||
|
|
||||||
|
### Step 2: Open-ended pass
|
||||||
|
|
||||||
|
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||||
|
|
||||||
|
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||||
|
|
||||||
|
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||||
|
|
||||||
|
### Step 3: Confirm
|
||||||
|
|
||||||
|
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||||
|
|
||||||
|
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||||
|
|
||||||
|
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||||
|
|
||||||
|
### Step 4: Record
|
||||||
|
|
||||||
|
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||||
|
|
||||||
|
Record this:
|
||||||
|
|
||||||
|
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||||
|
|
||||||
|
Not this:
|
||||||
|
|
||||||
|
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||||
|
|
||||||
|
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||||
|
|
||||||
|
### Step 5: Summarize
|
||||||
|
|
||||||
|
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||||
|
|
||||||
|
## Glob mapping
|
||||||
|
|
||||||
|
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||||
|
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||||
|
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||||
|
- Tests: `tests/**`.
|
||||||
|
- Migrations and database: `database/migrations/**`.
|
||||||
|
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||||
|
|
||||||
|
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||||
|
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||||
|
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||||
|
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||||
|
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||||
137
.claude/skills/infer-conventions/references/checklist.md
Normal file
137
.claude/skills/infer-conventions/references/checklist.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Detection Checklist
|
||||||
|
|
||||||
|
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||||
|
|
||||||
|
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Validation & HTTP input
|
||||||
|
|
||||||
|
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||||
|
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||||
|
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||||
|
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||||
|
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||||
|
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||||
|
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||||
|
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||||
|
|
||||||
|
## B. Controllers & routing
|
||||||
|
|
||||||
|
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||||
|
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||||
|
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||||
|
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||||
|
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||||
|
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||||
|
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||||
|
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||||
|
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||||
|
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||||
|
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||||
|
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||||
|
|
||||||
|
## C. Authorization
|
||||||
|
|
||||||
|
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||||
|
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||||
|
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||||
|
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||||
|
|
||||||
|
## D. Eloquent & models
|
||||||
|
|
||||||
|
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||||
|
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||||
|
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||||
|
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||||
|
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||||
|
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||||
|
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||||
|
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||||
|
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||||
|
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||||
|
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||||
|
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||||
|
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||||
|
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||||
|
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||||
|
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||||
|
|
||||||
|
## E. Architecture & organization
|
||||||
|
|
||||||
|
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||||
|
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||||
|
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||||
|
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||||
|
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||||
|
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||||
|
24. Decoupling: events + listeners vs direct service calls.
|
||||||
|
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||||
|
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||||
|
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||||
|
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||||
|
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||||
|
27. Enums: backed vs pure; case naming; where they live.
|
||||||
|
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||||
|
|
||||||
|
## F. Frontend & views
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the items below apply.
|
||||||
|
|
||||||
|
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||||
|
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||||
|
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||||
|
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||||
|
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||||
|
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||||
|
|
||||||
|
## G. Database & migrations
|
||||||
|
|
||||||
|
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||||
|
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||||
|
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||||
|
- Hint: grep `function down` vs the migration count.
|
||||||
|
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||||
|
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||||
|
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||||
|
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||||
|
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||||
|
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||||
|
|
||||||
|
## H. Testing
|
||||||
|
|
||||||
|
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||||
|
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||||
|
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||||
|
- Hint: grep those trait names in `tests/`.
|
||||||
|
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||||
|
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||||
|
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||||
|
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||||
|
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||||
|
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||||
|
|
||||||
|
## I. Responses & API resources
|
||||||
|
|
||||||
|
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||||
|
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||||
|
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||||
|
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||||
|
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||||
|
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||||
|
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||||
|
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||||
|
|
||||||
|
## J. Strings, collections & dates
|
||||||
|
|
||||||
|
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||||
|
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||||
|
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||||
|
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||||
|
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||||
|
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||||
|
|
@ -8,183 +8,52 @@
|
||||||
|
|
||||||
# Laravel Best Practices
|
# Laravel Best Practices
|
||||||
|
|
||||||
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||||
|
|
||||||
## Consistency First
|
## Consistency First
|
||||||
|
|
||||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||||
|
|
||||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### 1. Database Performance → `rules/db-performance.md`
|
|
||||||
|
|
||||||
- Eager load with `with()` to prevent N+1 queries
|
|
||||||
- Enable `Model::preventLazyLoading()` in development
|
|
||||||
- Select only needed columns, avoid `SELECT *`
|
|
||||||
- `chunk()` / `chunkById()` for large datasets
|
|
||||||
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
|
||||||
- `withCount()` instead of loading relations to count
|
|
||||||
- `cursor()` for memory-efficient read-only iteration
|
|
||||||
- Never query in Blade templates
|
|
||||||
|
|
||||||
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
|
||||||
|
|
||||||
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
|
||||||
- Dynamic relationships via subquery FK + `belongsTo`
|
|
||||||
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
|
||||||
- `setRelation()` to prevent circular N+1 queries
|
|
||||||
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
|
||||||
- Two simple queries can beat one complex query
|
|
||||||
- Compound indexes matching `orderBy` column order
|
|
||||||
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
|
||||||
|
|
||||||
### 3. Security → `rules/security.md`
|
|
||||||
|
|
||||||
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
|
||||||
- No raw SQL with user input — use Eloquent or query builder
|
|
||||||
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
|
||||||
- Validate MIME type, extension, and size for file uploads
|
|
||||||
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
|
||||||
|
|
||||||
### 4. Caching → `rules/caching.md`
|
|
||||||
|
|
||||||
- `Cache::remember()` over manual get/put
|
|
||||||
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
|
||||||
- `Cache::memo()` to avoid redundant cache hits within a request
|
|
||||||
- Cache tags to invalidate related groups
|
|
||||||
- `Cache::add()` for atomic conditional writes
|
|
||||||
- `once()` to memoize per-request or per-object lifetime
|
|
||||||
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
|
||||||
- Failover cache stores in production
|
|
||||||
|
|
||||||
### 5. Eloquent Patterns → `rules/eloquent.md`
|
|
||||||
|
|
||||||
- Correct relationship types with return type hints
|
|
||||||
- Local scopes for reusable query constraints
|
|
||||||
- Global scopes sparingly — document their existence
|
|
||||||
- Attribute casts in the `casts()` method
|
|
||||||
- Cast date columns, use Carbon instances in templates
|
|
||||||
- `whereBelongsTo($model)` for cleaner queries
|
|
||||||
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
|
||||||
|
|
||||||
### 6. Validation & Forms → `rules/validation.md`
|
|
||||||
|
|
||||||
- Form Request classes, not inline validation
|
|
||||||
- Array notation `['required', 'email']` for new code; follow existing convention
|
|
||||||
- `$request->validated()` only — never `$request->all()`
|
|
||||||
- `Rule::when()` for conditional validation
|
|
||||||
- `after()` instead of `withValidator()`
|
|
||||||
|
|
||||||
### 7. Configuration → `rules/config.md`
|
|
||||||
|
|
||||||
- `env()` only inside config files
|
|
||||||
- `App::environment()` or `app()->isProduction()`
|
|
||||||
- Config, lang files, and constants over hardcoded text
|
|
||||||
|
|
||||||
### 8. Testing Patterns → `rules/testing.md`
|
|
||||||
|
|
||||||
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
|
||||||
- `assertModelExists()` over raw `assertDatabaseHas()`
|
|
||||||
- Factory states and sequences over manual overrides
|
|
||||||
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
|
||||||
- `recycle()` to share relationship instances across factories
|
|
||||||
|
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
|
||||||
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
|
||||||
- Horizon for complex multi-queue scenarios
|
|
||||||
|
|
||||||
### 10. Routing & Controllers → `rules/routing.md`
|
|
||||||
|
|
||||||
- Implicit route model binding
|
|
||||||
- Scoped bindings for nested resources
|
|
||||||
- `Route::resource()` or `apiResource()`
|
|
||||||
- Methods under 10 lines — extract to actions/services
|
|
||||||
- Type-hint Form Requests for auto-validation
|
|
||||||
|
|
||||||
### 11. HTTP Client → `rules/http-client.md`
|
|
||||||
|
|
||||||
- Explicit `timeout` and `connectTimeout` on every request
|
|
||||||
- `retry()` with exponential backoff for external APIs
|
|
||||||
- Check response status or use `throw()`
|
|
||||||
- `Http::pool()` for concurrent independent requests
|
|
||||||
- `Http::fake()` and `preventStrayRequests()` in tests
|
|
||||||
|
|
||||||
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
|
||||||
|
|
||||||
- Event discovery over manual registration; `event:cache` in production
|
|
||||||
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
|
||||||
- Queue notifications and mailables with `ShouldQueue`
|
|
||||||
- On-demand notifications for non-user recipients
|
|
||||||
- `HasLocalePreference` on notifiable models
|
|
||||||
- `assertQueued()` not `assertSent()` for queued mailables
|
|
||||||
- Markdown mailables for transactional emails
|
|
||||||
|
|
||||||
### 13. Error Handling → `rules/error-handling.md`
|
|
||||||
|
|
||||||
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
|
||||||
- `ShouldntReport` for exceptions that should never log
|
|
||||||
- Throttle high-volume exceptions to protect log sinks
|
|
||||||
- `dontReportDuplicates()` for multi-catch scenarios
|
|
||||||
- Force JSON rendering for API routes
|
|
||||||
- Structured context via `context()` on exception classes
|
|
||||||
|
|
||||||
### 14. Task Scheduling → `rules/scheduling.md`
|
|
||||||
|
|
||||||
- `withoutOverlapping()` on variable-duration tasks
|
|
||||||
- `onOneServer()` on multi-server deployments
|
|
||||||
- `runInBackground()` for concurrent long tasks
|
|
||||||
- `environments()` to restrict to appropriate environments
|
|
||||||
- `takeUntilTimeout()` for time-bounded processing
|
|
||||||
- Schedule groups for shared configuration
|
|
||||||
|
|
||||||
### 15. Architecture → `rules/architecture.md`
|
|
||||||
|
|
||||||
- Single-purpose Action classes; dependency injection over `app()` helper
|
|
||||||
- Prefer official Laravel packages and follow conventions, don't override defaults
|
|
||||||
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
|
||||||
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
|
||||||
|
|
||||||
### 16. Migrations → `rules/migrations.md`
|
|
||||||
|
|
||||||
- Generate migrations with `php artisan make:migration`
|
|
||||||
- `constrained()` for foreign keys
|
|
||||||
- Never modify migrations that have run in production
|
|
||||||
- Add indexes in the migration, not as an afterthought
|
|
||||||
- Mirror column defaults in model `$attributes`
|
|
||||||
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
|
||||||
- One concern per migration — never mix DDL and DML
|
|
||||||
|
|
||||||
### 17. Collections → `rules/collections.md`
|
|
||||||
|
|
||||||
- Higher-order messages for simple collection operations
|
|
||||||
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
|
||||||
- `lazyById()` when updating records while iterating
|
|
||||||
- `toQuery()` for bulk operations on collections
|
|
||||||
|
|
||||||
### 18. Blade & Views → `rules/blade-views.md`
|
|
||||||
|
|
||||||
- `$attributes->merge()` in component templates
|
|
||||||
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
|
||||||
- View Composers for shared view data
|
|
||||||
- `@aware` for deeply nested component props
|
|
||||||
|
|
||||||
### 19. Conventions & Style → `rules/style.md`
|
|
||||||
|
|
||||||
- Follow Laravel naming conventions for all entities
|
|
||||||
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
|
||||||
- No JS/CSS in Blade, no HTML in PHP classes
|
|
||||||
- Code should be readable; comments only for config files
|
|
||||||
|
|
||||||
## How to Apply
|
## How to Apply
|
||||||
|
|
||||||
Always use a sub-agent to read rule files and explore this skill's content.
|
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
||||||
|
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
||||||
|
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
||||||
|
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
||||||
|
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
||||||
|
6. Re-read the diff against every mapped rule before finishing.
|
||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
## Rule Index
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
Cross-cutting changes often need more than one rule file.
|
||||||
|
|
||||||
|
| Concern | Read |
|
||||||
|
| --- | --- |
|
||||||
|
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
||||||
|
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
||||||
|
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
||||||
|
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
||||||
|
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
||||||
|
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
||||||
|
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
||||||
|
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
||||||
|
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
||||||
|
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
||||||
|
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
||||||
|
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
||||||
|
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
||||||
|
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
||||||
|
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
||||||
|
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
||||||
|
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
||||||
|
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
||||||
|
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||||
|
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
||||||
|
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
||||||
|
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ ## Single-Purpose Action Classes
|
||||||
{
|
{
|
||||||
public function __construct(private InventoryService $inventory) {}
|
public function __construct(private InventoryService $inventory) {}
|
||||||
|
|
||||||
public function execute(array $data): Order
|
public function handle(array $data): Order
|
||||||
{
|
{
|
||||||
$order = Order::create($data);
|
$order = Order::create($data);
|
||||||
$this->inventory->reserve($order);
|
$this->inventory->reserve($order);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ ## Use Local Scopes for Reusable Queries
|
||||||
|
|
||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
public function scopeActive(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function active(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,8 @@ ## Apply Global Scopes Sparingly
|
||||||
|
|
||||||
Correct (local scope you opt into):
|
Correct (local scope you opt into):
|
||||||
```php
|
```php
|
||||||
public function scopePublished(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function published(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('published', true);
|
return $query->where('published', true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ ## Use Laravel String & Array Helpers
|
||||||
// Incorrect
|
// Incorrect
|
||||||
$slug = strtolower(str_replace(' ', '-', $title));
|
$slug = strtolower(str_replace(' ', '-', $title));
|
||||||
$short = substr($text, 0, 100) . '...';
|
$short = substr($text, 0, 100) . '...';
|
||||||
$class = substr(strrchr('App\Models\User', '\'), 1);
|
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
||||||
|
|
||||||
// Correct
|
// Correct
|
||||||
$slug = Str::slug($title);
|
$slug = Str::slug($title);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: mcp-development
|
name: mcp-development
|
||||||
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
description: "Use this skill for Laravel MCP development. Trigger when creating or editing MCP tools, resources, prompts, servers, or UI apps in Laravel projects. Covers: artisan make:mcp-* generators, routes/ai.php, Tool/Resource/Prompt/AppResource classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, MCP debugging, MCP UI apps, the x-mcp::app Blade component, createMcpApp(), default AppResource handle() auto-infers view from class name, Response::view(), AppMeta/Csp/Permissions/appMeta() configuration, #[RendersApp] attribute, Library enum for CDN libraries (Tailwind, Alpine), and host theming via CSS variables. Use this whenever the user mentions MCP apps, MCP UI, interactive MCP resources, styling MCP apps with Tailwind or Alpine, or building visual interfaces for AI agents."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -12,6 +12,8 @@ ## Documentation
|
||||||
|
|
||||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
For MCP UI apps (interactive HTML resources), read `references/app.md` — it covers the full architecture, host theming CSS variables, tool-to-UI linking patterns, library scripts (Tailwind, Alpine via `Library`), and real-world examples.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
Register MCP servers in `routes/ai.php`:
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
@ -25,8 +27,6 @@ ## Basic Usage
|
||||||
|
|
||||||
### Creating MCP Primitives
|
### Creating MCP Primitives
|
||||||
|
|
||||||
Create MCP tools, resources, prompts, and servers using artisan commands:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan make:mcp-tool ToolName # Create a tool
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
|
@ -36,6 +36,8 @@ ### Creating MCP Primitives
|
||||||
|
|
||||||
php artisan make:mcp-server ServerName # Create a server
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
php artisan make:mcp-app-resource DashboardApp # Create a UI app (2 files)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
@ -44,23 +46,33 @@ ### Tools
|
||||||
|
|
||||||
<!-- MCP Tool Example -->
|
<!-- MCP Tool Example -->
|
||||||
```php
|
```php
|
||||||
|
use Illuminate\Json\Schema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\Server\Tool;
|
use Laravel\Mcp\Server\Tool;
|
||||||
use Laravel\Mcp\Server\Request;
|
|
||||||
use Laravel\Mcp\Server\Response;
|
|
||||||
|
|
||||||
class MyTool extends Tool
|
class MyTool extends Tool
|
||||||
{
|
{
|
||||||
|
protected string $description = 'Describe what this tool does';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $schema->string()->description('The name parameter')->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(Request $request): Response
|
public function handle(Request $request): Response
|
||||||
{
|
{
|
||||||
return new Response(['result' => 'success']);
|
$request->validate(['name' => 'required|string']);
|
||||||
|
|
||||||
|
return Response::text('Hello, '.$request->get('name'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Registering Primitives in a Server
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
|
||||||
|
|
||||||
<!-- Register Primitives in MCP Server -->
|
<!-- Register Primitives in MCP Server -->
|
||||||
```php
|
```php
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
|
|
@ -81,6 +93,10 @@ ### Registering Primitives in a Server
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MCP UI Apps
|
||||||
|
|
||||||
|
For MCP UI apps, read `references/app.md` — it covers quick start examples, full architecture, AppMeta/Csp/Permissions, `#[RendersApp]` tool linking, library scripts (Tailwind/Alpine via `Library`), host theming CSS variables, and real-world patterns.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
1. Check `routes/ai.php` for proper registration
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
|
@ -92,5 +108,5 @@ ## Common Pitfalls
|
||||||
- Using HTTPS locally with Node-based MCP clients
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
- Not using `search-docs` for the latest MCP documentation
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
- Not registering MCP server routes in `routes/ai.php`
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically
|
||||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
|
||||||
940
.claude/skills/mcp-development/references/app.md
Normal file
940
.claude/skills/mcp-development/references/app.md
Normal file
|
|
@ -0,0 +1,940 @@
|
||||||
|
# MCP UI Apps Reference
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
`make:mcp-app-resource DashboardApp` generates two files — a PHP registration stub and a Blade view. The entire app lives in the Blade view.
|
||||||
|
|
||||||
|
**PHP class** — renders the Blade view. The view name is auto-inferred from the class name (`mcp.<kebab-class-name>`), so the generated stub needs no changes unless you're passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blade view** — HTML structure + inline JS, everything in one file:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<h1>Dashboard App</h1>
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
`createMcpApp` is a global pre-bundled by the package — no npm install, no imports, no Vite required. It handles connection, error handling, and host theming automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept: Tool + Resource
|
||||||
|
|
||||||
|
Every MCP App is built from two parts linked together:
|
||||||
|
|
||||||
|
- **Tool** — called by the LLM or host. Returns a text/data response and tells the host which UI resource to render via `_meta.ui.resourceUri`.
|
||||||
|
- **AppResource** — serves the self-contained HTML app. The host fetches it after the tool is called and renders it in a sandboxed iframe.
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM calls Tool
|
||||||
|
└─► Tool response includes _meta.ui.resourceUri → "ui://dashboard-app"
|
||||||
|
└─► Host fetches AppResource at that URI
|
||||||
|
└─► Host renders HTML in sandboxed iframe
|
||||||
|
└─► createMcpApp() connects the iframe back to the server
|
||||||
|
└─► UI calls app-only tools to load/refresh data
|
||||||
|
```
|
||||||
|
|
||||||
|
The link is declared once with `#[RendersApp]` on the tool:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, the host handles fetching and rendering the resource automatically — you never reference the URI by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
MCP Apps add interactive UI to the Model Context Protocol. The server returns self-contained HTML with all JS/CSS inlined. The host renders it in a sandboxed iframe. Apps communicate back via `createMcpApp()` — a pre-bundled global implementing the MCP UI PostMessage protocol.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Host (Claude, ChatGPT, VS Code) │
|
||||||
|
│ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ Sandboxed iframe │ │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Your MCP App (HTML/JS/CSS) │ │ │
|
||||||
|
│ │ │ - Rendered by AppResource │ │ │
|
||||||
|
│ │ │ - Single self-contained HTML │ │ │
|
||||||
|
│ │ │ - Themed via host CSS vars │ │ │
|
||||||
|
│ │ └─────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────┘ │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│ MCP Protocol (JSON-RPC)
|
||||||
|
┌──────────────────▼──────────────────────────┐
|
||||||
|
│ Laravel MCP Server │
|
||||||
|
│ - AppResource → self-contained HTML │
|
||||||
|
│ - Tool #[RendersApp] → triggers UI display │
|
||||||
|
│ - resources/read → serves HTML + _meta.ui │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The server automatically advertises `io.modelcontextprotocol/ui` capability when any `AppResource` is registered. The client declares support in `capabilities.extensions["io.modelcontextprotocol/ui"]` during the initialize handshake.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side
|
||||||
|
|
||||||
|
Minimal case — `handle()` renders the Blade view, entire app lives there:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-renders `resources/views/mcp/dashboard-app.blade.php` with `$title` available via `$this->title()`.
|
||||||
|
|
||||||
|
Override `handle()` only when passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class AnalyticsDashboard extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.analytics-dashboard', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
'metrics' => Metric::latest()->take(10)->get(),
|
||||||
|
'totalUsers' => User::count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Response::view($view, $data = [], $mergeData = [])` renders a Blade view and returns it as text.
|
||||||
|
|
||||||
|
`Response::html($path)` reads an HTML file from disk and returns its content. Relative paths resolve via `resource_path()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class StaticApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::html('mcp/static-app.html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppMeta Configuration
|
||||||
|
|
||||||
|
The simplest way to configure UI metadata is via the `#[AppMeta]` attribute directly on your resource class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\AppMeta;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
#[AppMeta(
|
||||||
|
connectDomains: ['https://api.stripe.com'],
|
||||||
|
permissions: [Permission::Camera, Permission::ClipboardWrite],
|
||||||
|
prefersBorder: true,
|
||||||
|
libraries: [Library::Tailwind, Library::Alpine],
|
||||||
|
)]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For dynamic or computed configuration, override `appMeta()` instead:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\AppMeta;
|
||||||
|
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains(config('services.api.domains')))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind)
|
||||||
|
->domain('sandbox.example.com');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permission Enum
|
||||||
|
|
||||||
|
Use the `Permission` enum for type-safe permission configuration:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
Permission::Camera // 'camera'
|
||||||
|
Permission::Microphone // 'microphone'
|
||||||
|
Permission::Geolocation // 'geolocation'
|
||||||
|
Permission::ClipboardWrite // 'clipboardWrite'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Csp
|
||||||
|
|
||||||
|
Controls what external domains the iframe can access:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Csp::make()
|
||||||
|
->connectDomains(['https://api.example.com']) // fetch, XHR, WebSocket origins
|
||||||
|
->resourceDomains(['https://cdn.example.com']) // images, scripts, fonts, media
|
||||||
|
->frameDomains(['https://embed.example.com']) // nested iframe origins
|
||||||
|
->baseUriDomains(['https://base.example.com']); // base URI origins
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
Permissions::make()->allow(Permission::Camera, Permission::ClipboardWrite);
|
||||||
|
|
||||||
|
Permissions::make()
|
||||||
|
->camera()
|
||||||
|
->microphone()
|
||||||
|
->geolocation()
|
||||||
|
->clipboardWrite();
|
||||||
|
```
|
||||||
|
|
||||||
|
Each enabled permission serializes as `"camera": {}` per the MCP spec.
|
||||||
|
|
||||||
|
#### AppMeta
|
||||||
|
|
||||||
|
```php
|
||||||
|
AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains([...]))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine)
|
||||||
|
->domain('sandbox.example.com') // dedicated sandbox origin (OAuth/CORS)
|
||||||
|
->prefersBorder(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
`prefersBorder` defaults to `true`. `toArray()` omits null fields and empty nested objects. Library CDN domains are automatically merged into `csp.resourceDomains`.
|
||||||
|
|
||||||
|
#### domain
|
||||||
|
|
||||||
|
The `domain` field provides a stable origin that external APIs can allowlist for CORS. It is automatically resolved from `config('app.url')` (your `APP_URL` env variable) via `resolvedAppMeta()`, so most apps need no configuration. Override only when a resource needs a different origin:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(domain: 'custom.example.com')]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Library Scripts
|
||||||
|
|
||||||
|
The `libraries` parameter adds pre-configured CDN scripts to the `<head>` of your app. Available libraries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
|
||||||
|
Library::Tailwind // Tailwind CSS CDN + dark mode config
|
||||||
|
Library::Alpine // Alpine.js CDN + x-cloak style
|
||||||
|
```
|
||||||
|
|
||||||
|
When libraries are specified, the package automatically:
|
||||||
|
|
||||||
|
1. Injects the CDN `<script>` tags into the Blade view's `<head>` (after the MCP SDK, before your `<x-slot:head>`)
|
||||||
|
2. Merges each library's CDN domains into `csp.resourceDomains` so the host allows loading them
|
||||||
|
|
||||||
|
Via attribute:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(libraries: [Library::Tailwind])]
|
||||||
|
class StyledApp extends AppResource
|
||||||
|
{
|
||||||
|
// Tailwind is available in the Blade view — no extra setup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Via fluent builder:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## View Layer
|
||||||
|
|
||||||
|
### `<x-mcp::app>` Blade Component
|
||||||
|
|
||||||
|
Renders a complete self-contained HTML document with the MCP SDK inlined. `createMcpApp` is available globally.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props and slots:**
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
| ------------- | ------------- | ---------------------------------------------------- |
|
||||||
|
| `title` | Prop | Sets `<title>`. Optional. |
|
||||||
|
| `head` | Named slot | Injected into `<head>` after the inlined SDK script. |
|
||||||
|
| Default slot | Slot | Body content. |
|
||||||
|
| `$attributes` | Attribute bag | Forwarded to `<body>` (e.g. `class="dark"`). |
|
||||||
|
|
||||||
|
The SDK is loaded from the `mcp.sdk` singleton (registered by `McpServiceProvider`) and inlined directly in a `<script>` tag. Library scripts (Tailwind, Alpine) configured via `#[AppMeta]` are injected after the SDK and before the `head` slot.
|
||||||
|
|
||||||
|
Publish the component: `php artisan vendor:publish --tag=mcp-views`.
|
||||||
|
|
||||||
|
To pass server-side data to JS, embed it as `data-*` attributes:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div id="app" data-users="{{ $users->toJson() }}">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const users = JSON.parse(document.getElementById("app").dataset.users);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client-Side
|
||||||
|
|
||||||
|
This package provides a simple MCP client library to easily work with client interactions.
|
||||||
|
|
||||||
|
### createMcpApp
|
||||||
|
|
||||||
|
Pre-bundled and inlined automatically — no npm install or imports required.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
// app is ready — connection established, theming applied
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### app.callServerTool()
|
||||||
|
|
||||||
|
Accepts an object or positional arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form
|
||||||
|
const result = await app.callServerTool({ name: 'get-analytics', arguments: { dateRange: '7d' } });
|
||||||
|
|
||||||
|
// Positional form
|
||||||
|
const result = await app.callServerTool('get-analytics', { dateRange: '7d' });
|
||||||
|
|
||||||
|
// result structure depends on the server's tool response
|
||||||
|
const text = result.content[0]?.text ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
All tool results share a standard structure:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
| --------- | --------- | ------------------------------------------------------------------------- |
|
||||||
|
| `content` | `Array` | Content items returned by the tool (each has `type` and `text` or `data`) |
|
||||||
|
| `isError` | `boolean` | `true` when the tool returned an error response |
|
||||||
|
|
||||||
|
Always check `result.isError` before consuming `content`. See [Error Handling](#error-handling) for a full example.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
#### app.listResources()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resources = await app.listResources();
|
||||||
|
// or with cursor for pagination
|
||||||
|
const resources = await app.listResources("cursor-value");
|
||||||
|
// or object form
|
||||||
|
const resources = await app.listResources({ cursor: "cursor-value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.readResource()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resource = await app.readResource("ui://my-resource");
|
||||||
|
// or object form
|
||||||
|
const resource = await app.readResource({ uri: "ui://my-resource" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
#### app.sendMessage()
|
||||||
|
|
||||||
|
Send a message to the model (creates a conversation turn):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form with structured content
|
||||||
|
await app.sendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "User submitted the form." }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shorthand — plain string content with optional role (defaults to 'user')
|
||||||
|
await app.sendMessage("User submitted the form.");
|
||||||
|
await app.sendMessage("System event occurred.", "user");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host Context
|
||||||
|
|
||||||
|
#### app.getHostContext()
|
||||||
|
|
||||||
|
Returns the current host context, including theme and style variables:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ctx = app.getHostContext();
|
||||||
|
ctx?.theme; // 'light' | 'dark'
|
||||||
|
ctx?.styles?.variables; // CSS variable map from host
|
||||||
|
ctx?.styles?.css?.fonts; // font CSS from host
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostInfo()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const info = app.getHostInfo();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostCapabilities()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const caps = app.getHostCapabilities();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation & Files
|
||||||
|
|
||||||
|
#### app.openLink()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.openLink("https://example.com");
|
||||||
|
// or object form
|
||||||
|
await app.openLink({ url: "https://example.com" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.downloadFile()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.downloadFile("file contents here");
|
||||||
|
// or object form
|
||||||
|
await app.downloadFile({ contents: "file contents here" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
#### app.requestDisplayMode()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.requestDisplayMode("fullscreen");
|
||||||
|
// or object form
|
||||||
|
await app.requestDisplayMode({ mode: "fullscreen" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.resize() / app.autoResize()
|
||||||
|
|
||||||
|
`resize()` sends a one-time size notification. `autoResize()` uses `ResizeObserver` to continuously notify the host of size changes. It returns a cleanup function that disconnects the observer — useful if you need to stop observing before teardown. The observer is also automatically disconnected on teardown.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stopObserving = app.autoResize();
|
||||||
|
|
||||||
|
// Later, if needed:
|
||||||
|
stopObserving();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context
|
||||||
|
|
||||||
|
#### app.updateModelContext()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.updateModelContext({ key: "value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
#### app.requestTeardown()
|
||||||
|
|
||||||
|
Sends a teardown notification to the host.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.requestTeardown();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
#### app.sendLog()
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Positional form
|
||||||
|
await app.sendLog("info", "Processing started", "my-logger");
|
||||||
|
|
||||||
|
// Object form
|
||||||
|
await app.sendLog({
|
||||||
|
level: "info",
|
||||||
|
data: "Processing started",
|
||||||
|
logger: "my-logger",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handlers
|
||||||
|
|
||||||
|
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInput((params) => {
|
||||||
|
/* tool input received */
|
||||||
|
});
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
/* partial tool input */
|
||||||
|
});
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
/* tool result received */
|
||||||
|
});
|
||||||
|
app.onToolCancelled((params) => {
|
||||||
|
/* tool was cancelled */
|
||||||
|
});
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
/* theme/styles changed */
|
||||||
|
});
|
||||||
|
app.onTeardown(async () => {
|
||||||
|
/* cleanup before teardown */
|
||||||
|
});
|
||||||
|
app.onCallTool(async (params) => {
|
||||||
|
/* host requests tool call */
|
||||||
|
});
|
||||||
|
app.onListTools(async (params) => {
|
||||||
|
/* host requests tool list */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host Theming
|
||||||
|
|
||||||
|
`createMcpApp` automatically applies host theming on connect and on context change:
|
||||||
|
|
||||||
|
- Sets `data-theme` attribute and `color-scheme` on `<html>`
|
||||||
|
- Applies CSS variables from `hostContext.styles.variables` to `:root`
|
||||||
|
- Injects font CSS from `hostContext.styles.css.fonts` into a `<style>` tag
|
||||||
|
|
||||||
|
The specific CSS variables available depend on the host. Always provide fallback values — use `light-dark()` for theme-aware defaults:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--color-background-primary: light-dark(#ffffff, #171717);
|
||||||
|
--color-text-primary: light-dark(#171717, #fafafa);
|
||||||
|
--color-text-secondary: light-dark(#525252, #a3a3a3);
|
||||||
|
--color-border-primary: light-dark(#e5e5e5, #404040);
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool-to-UI Linking
|
||||||
|
|
||||||
|
### #[RendersApp] Attribute
|
||||||
|
|
||||||
|
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\RendersApp;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Visibility;
|
||||||
|
|
||||||
|
// Both model and app can call this tool (default)
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool { ... }
|
||||||
|
|
||||||
|
// Only the app can call this tool (private to the UI)
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class RefreshDashboardData extends Tool { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visibility:**
|
||||||
|
|
||||||
|
The `Visibility` enum (`Laravel\Mcp\Server\Ui\Enums\Visibility`) has two cases: `Model` and `App`. The default is `[Visibility::Model, Visibility::App]`.
|
||||||
|
|
||||||
|
| Visibility | Model | App | Use case |
|
||||||
|
| -------------------------------------- | ----- | --- | ------------------------------------------------------ |
|
||||||
|
| `[Visibility::Model, Visibility::App]` | Yes | Yes | Primary tools that trigger UI display |
|
||||||
|
| `[Visibility::App]` | No | Yes | Backend actions the UI calls (refresh, save, paginate) |
|
||||||
|
| `[Visibility::Model]` | Yes | No | Model-only tools linked to a UI |
|
||||||
|
|
||||||
|
### Primary + Private Pattern
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetDashboardMetrics extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json(Metric::latest()->take(50)->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
it('returns html content', function () {
|
||||||
|
MyServer::readResource(DashboardApp::class)
|
||||||
|
->assertSee('<div id="app">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct mime type and uri scheme', function () {
|
||||||
|
$resource = new DashboardApp;
|
||||||
|
$data = $resource->toArray();
|
||||||
|
|
||||||
|
expect($data['mimeType'])->toBe('text/html;profile=mcp-app')
|
||||||
|
->and($data['_meta']['ui'])->toBeArray()
|
||||||
|
->and($resource->uri())->toStartWith('ui://');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures ui meta correctly', function () {
|
||||||
|
$meta = (new DashboardApp)->resolvedAppMeta();
|
||||||
|
|
||||||
|
expect($meta['csp']['connectDomains'])->toContain('https://api.example.com')
|
||||||
|
->and($meta['permissions'])->toHaveKey('clipboardWrite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes ui metadata in tool listing', function () {
|
||||||
|
MyServer::listTools()->assertSee('show-dashboard');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Real-time Polling
|
||||||
|
|
||||||
|
Use app-only tools to fetch fresh data at regular intervals from the UI:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: MonitorApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetMonitorData extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch latest monitor metrics';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json([
|
||||||
|
'cpu' => sys_getloadavg()[0],
|
||||||
|
'memory' => memory_get_usage(true),
|
||||||
|
'timestamp' => now()->toISOString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function poll() {
|
||||||
|
const result = await app.callServerTool('get-monitor-data');
|
||||||
|
const data = JSON.parse(result.content[0]?.text ?? '{}');
|
||||||
|
document.getElementById('cpu').textContent = data.cpu;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(poll, 2000);
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chunked Data Loading
|
||||||
|
|
||||||
|
For large datasets, implement pagination via app-only tools:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: LogViewerApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetLogChunk extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch a chunk of log entries';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'offset' => $schema->integer()->description('Byte offset to start from')->required(),
|
||||||
|
'limit' => $schema->integer()->description('Max bytes to return'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['offset' => 'required|integer', 'limit' => 'integer']);
|
||||||
|
|
||||||
|
$offset = $request->get('offset');
|
||||||
|
$limit = $request->get('limit', 500_000);
|
||||||
|
$content = Storage::get('logs/app.log');
|
||||||
|
$chunk = substr($content, $offset, $limit);
|
||||||
|
|
||||||
|
return Response::json([
|
||||||
|
'data' => $chunk,
|
||||||
|
'offset' => $offset,
|
||||||
|
'totalBytes' => strlen($content),
|
||||||
|
'hasMore' => ($offset + $limit) < strlen($content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Resource Serving
|
||||||
|
|
||||||
|
Deliver images and binary content through MCP resources using `Response::blob()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: GalleryApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetImage extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch an image by ID';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['id' => 'required|integer']);
|
||||||
|
|
||||||
|
$image = Image::findOrFail($request->get('id'));
|
||||||
|
$data = base64_encode(Storage::get($image->path));
|
||||||
|
|
||||||
|
return Response::blob($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the client, convert the base64 blob to a data URI for rendering:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await app.callServerTool('get-image', { id: 42 });
|
||||||
|
const blob = result.content[0];
|
||||||
|
img.src = `data:${blob.mimeType};base64,${blob.data}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Argument Previews
|
||||||
|
|
||||||
|
Use `onToolInputPartial` to show previews as the model streams tool arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
try {
|
||||||
|
const partial = JSON.parse(params.arguments);
|
||||||
|
if (partial.query) {
|
||||||
|
document.getElementById("preview").textContent = partial.query;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// partial JSON — ignore until parseable
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
const data = JSON.parse(params.result.content[0]?.text ?? "{}");
|
||||||
|
renderResults(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View State Persistence
|
||||||
|
|
||||||
|
Use `localStorage` to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const STATE_KEY = "dashboard-view-state";
|
||||||
|
|
||||||
|
// Restore from localStorage
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
||||||
|
if (saved.activeTab) selectTab(saved.activeTab);
|
||||||
|
|
||||||
|
// Save on interaction
|
||||||
|
function saveState(state) {
|
||||||
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For durable state, persist server-side
|
||||||
|
async function saveServerState(state) {
|
||||||
|
await app.callServerTool('save-dashboard-state', { state: JSON.stringify(state) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fullscreen Toggling
|
||||||
|
|
||||||
|
Switch between inline and fullscreen display modes and react to mode changes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById("expand-btn").addEventListener("click", () => {
|
||||||
|
app.requestDisplayMode("fullscreen");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
"fullscreen",
|
||||||
|
ctx.displayMode === "fullscreen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context Updates
|
||||||
|
|
||||||
|
Keep the model informed about what the user is viewing so it can provide relevant assistance:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function notifyContext(view, detail) {
|
||||||
|
await app.updateModelContext({
|
||||||
|
currentView: view,
|
||||||
|
detail: detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify on tab change
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
notifyContext(tab.dataset.view, { filters: getActiveFilters() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// For large payloads, follow up with sendMessage
|
||||||
|
await app.updateModelContext({ currentView: "report", rows: 5000 });
|
||||||
|
await app.sendMessage("The user is viewing a report with 5000 rows.");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause Offscreen Views
|
||||||
|
|
||||||
|
Conserve resources by pausing animations and polling when the view is not visible:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (!pollInterval) {
|
||||||
|
pollInterval = setInterval(fetchData, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
entry.isIntersecting ? startPolling() : stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement);
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Return `Response::error()` from tools and use `updateModelContext()` to signal degraded state:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class ProcessData extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['input' => 'required|string']);
|
||||||
|
|
||||||
|
if (strlen($request->get('input')) > 10_000) {
|
||||||
|
return Response::error('Input exceeds 10KB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(process($request->get('input')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const result = await app.callServerTool('process-data', { input: value });
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
document.getElementById("error").textContent =
|
||||||
|
result.content[0]?.text ?? "Unknown error";
|
||||||
|
await app.updateModelContext({
|
||||||
|
state: "error",
|
||||||
|
message: result.content[0]?.text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOutput(JSON.parse(result.content[0]?.text ?? "{}"));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
@ -11,7 +11,7 @@ Laravel + Inertia v3 + Vue 3 + Tailwind v4 application. You are an expert on the
|
||||||
- inertiajs/inertia-laravel v3, @inertiajs/vue3 v3
|
- inertiajs/inertia-laravel v3, @inertiajs/vue3 v3
|
||||||
- laravel/cashier v16, laravel/horizon v5, laravel/passport v13
|
- laravel/cashier v16, laravel/horizon v5, laravel/passport v13
|
||||||
- laravel/pennant v1, laravel/reverb v1, laravel/socialite v5
|
- laravel/pennant v1, laravel/reverb v1, laravel/socialite v5
|
||||||
- laravel/wayfinder v0, laravel/ai v0, laravel/boost v2, laravel/mcp v0
|
- laravel/wayfinder v0, laravel/ai v0, laravel/boost v2.5, laravel/mcp v0.9
|
||||||
- laravel/nightwatch v1, laravel/telescope v5, laravel/pail v1, laravel/pint v1, laravel/sail v1
|
- laravel/nightwatch v1, laravel/telescope v5, laravel/pail v1, laravel/pint v1, laravel/sail v1
|
||||||
- laravel/prompts v0
|
- laravel/prompts v0
|
||||||
- pestphp/pest v5, phpunit/phpunit v13
|
- pestphp/pest v5, phpunit/phpunit v13
|
||||||
|
|
|
||||||
104
.cursor/skills/infer-conventions/SKILL.md
Normal file
104
.cursor/skills/infer-conventions/SKILL.md
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
---
|
||||||
|
name: infer-conventions
|
||||||
|
description: "Use this skill to analyze how a Laravel application is actually written and record its conventions as shared rules. Trigger when the user wants to detect, infer, document, or standardize project conventions or coding style, set up or grow `.ai/rules`, resolve mixed or conflicting patterns (e.g. \"are we using Form Requests or inline validation?\"), or onboard agents and teammates to \"how we do things here\". Covers: a systematic sweep of ~49 Laravel convention dimensions (validation, models, architecture, testing, frontend, database, console), open-ended house-pattern discovery, conflict reporting, and recording rules scoped to the right paths via the Boost `record-rule` MCP tool. Do not use for one-off code review, enforcing formatting a linter already handles, or editing `.ai/rules` files by hand."
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
author: laravel
|
||||||
|
---
|
||||||
|
|
||||||
|
# Infer Conventions
|
||||||
|
|
||||||
|
Learn how this application writes Laravel, then record what you learn as durable, path-scoped rules other agents will read. You are documenting reality, not improving it.
|
||||||
|
|
||||||
|
## Ground Rules (read before you start)
|
||||||
|
|
||||||
|
- Consistency first. The codebase's majority style is the convention. Never judge it, never propose a "better" pattern, never record what the code should do. If the app validates inline everywhere, that is the rule, even if Form Requests would be nicer.
|
||||||
|
- Skip what an active tool produces, keep what a tool would fight. Inspect the project's Pint and Rector configuration first; a Rector transformation is tooling-owned only when its package and relevant rule or set are installed and enabled. Active tools may rewrite code toward one canonical form: `$casts` to `casts()`, `$fillable` to attributes, magic accessors to the `Attribute` class, pipe-string rules to arrays, `$signature` to `#[Signature]`, named migrations to anonymous, and many more. When the app already sits at an active tool's target form, the tool owns it, so record nothing. But when the app deliberately holds a form an active tool would refactor away, such as legacy `getXxxAttribute()` accessors the `Attribute` class would replace, no tool can reproduce that choice and an agent defaults the other way. That against-the-grain hold is exactly what to record.
|
||||||
|
- Record decisions, not defaults. A consistent pattern earns a rule only when it reflects a choice: the app took one valid option where the framework or common practice offered others, or the pattern would surprise a competent agent. Framework defaults steer nothing, so skip them: anonymous migrations, `$signature` commands, `ShouldQueue` jobs, `casts()` on Laravel 11+, named routes, Rule objects in `app/Rules`, and `Mail::fake()` or `Bus::fake()` to isolate framework services. A real fork is not enough on its own. Weigh the side the app took, and record only the side an agent would not reach for by itself: inline closures everywhere, legacy accessors, a bespoke query layer. Watch for the false fork too. "No Mockery" next to facade fakes is not a choice against Mockery, because they double different things. The test for every candidate: without this rule, would the next agent plausibly write it differently? Only "yes" earns a rule.
|
||||||
|
- Architecture choices are the gold. Record presence and deliberate absence. The structural pattern the app commits to is the highest-signal convention and the one no tool can decide: Action classes and how they are invoked (`handle` / `execute` / `__invoke`), service objects, dedicated query objects exposing `builder()`, DTOs (spatie/laravel-data vs readonly classes), Form Request validation vs inline, an events and listeners spine vs direct calls, and domain or module folders. Also record a consistent non-pattern, such as "query Eloquent directly in controllers, no repository layer", so the next agent matches the app's altitude instead of over-engineering.
|
||||||
|
- Never duplicate `.ai/rules`. Read `.ai/rules/index.md` and the area files before the sweep. A dimension already covered there is marked done and skipped.
|
||||||
|
- Evidence or silence. A convention needs at least 3 consistent examples and no meaningful rival to become a candidate. Every Step 1 verdict applies this bar.
|
||||||
|
- The recorded rule states the convention, nothing else. One or two imperative lines: this project does X, so do X here. Keep detection evidence out. No counts, ratios, current usage, file lists, or example paths, because that is proof for the confirm step, not part of the rule. One short syntax fragment at most, and point to `search-docs` for API details.
|
||||||
|
|
||||||
|
## Process
|
||||||
|
|
||||||
|
Each step ends on a checkable completion criterion. Do not advance until it holds.
|
||||||
|
|
||||||
|
Fan out when you can. The sweep is embarrassingly parallel. If your environment can spawn subagents (a Task, dispatch, or equivalent tool), do Step 0 yourself, then hand each checklist group (A to J) and the architecture map to its own subagent. Each subagent runs the greps, reads a few representative files, and returns structured verdicts (dimension, verdict, evidence, proposed glob / title / note). You aggregate, dedupe, then run Steps 3 to 5. It is far faster on a real app. No subagents available? Run the steps in sequence, with the same bar and the same output.
|
||||||
|
|
||||||
|
### Step 0: Orient
|
||||||
|
|
||||||
|
Read `composer.json` (installed packages tell you which checklist groups apply), the `pint.json` / PHPStan / Rector config, `.ai/rules/index.md` if present, and most important, map the `app/` tree. List every directory under `app/` (and any `Modules/`, `src/`, `packages/`, or domain root). Every folder beyond Laravel's default skeleton (`Http`, `Models`, `Providers`, `Console`, `Exceptions`) is a structural pattern the app committed to and a high-value rule waiting to be written: `Actions`, `Services`, `Data` or DTOs, `Queries`, `Repositories`, `ViewModels`, `Pipelines`, `Support`, `Enums`, `Contracts`, `Observers`, or `Domain` and module roots. Note each one. You will confirm how it is used in Step 2.
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the frontend checklist group applies. Sweep it.
|
||||||
|
|
||||||
|
Done when: you have the applicable checklist groups, the dimensions already recorded in `.ai/rules`, and a list of every non-default `app/` directory mapped to the pattern it represents.
|
||||||
|
|
||||||
|
### Step 1: Predefined sweep
|
||||||
|
|
||||||
|
Open `references/checklist.md` and work every applicable dimension using its search hints. Give each exactly one verdict:
|
||||||
|
|
||||||
|
- Pattern. Clears the bar, rival under ~20% of sites, and reflects a real choice (passes the decisions-not-defaults test). A recording candidate. Cite 2 to 3 example files.
|
||||||
|
- Conflict. Both styles present in meaningful numbers. Report the split with counts and example files. Never record a preferred winner while the code remains mixed, even in yolo, because that would describe an aspiration rather than reality. Record only if the user identifies a stable path or context boundary that explains both styles; otherwise defer until the code is reconciled.
|
||||||
|
- Default. Consistent, but a framework or common-practice default the agent already writes unprompted. Skip it as a no-op, not a convention.
|
||||||
|
- No signal. Under the bar: feature unused, or too few examples. Skip silently (one summary line at most).
|
||||||
|
- Tooling-owned or Already-recorded. Skip per the ground rules.
|
||||||
|
|
||||||
|
Done when: every applicable dimension carries exactly one of those verdicts.
|
||||||
|
|
||||||
|
### Step 2: Open-ended pass
|
||||||
|
|
||||||
|
First, close out the architecture map from Step 0. For every non-default `app/` directory you listed, confirm how the pattern is used and apply the same evidence and decisions-not-defaults tests as Step 1. Generator-standard or sparsely used directories such as `Rules`, `Observers`, `Mail`, and `Notifications` are signals to inspect, not automatic conventions. Make genuine structural patterns candidates: Action classes invoked via `handle` / `execute` / `__invoke`, Services constructor-injected, `Queries` objects exposing `builder(): Builder`, DTOs as readonly classes or spatie/laravel-data, module or domain folders as the unit of organization. Scope each qualifying pattern to its own directory glob. Also record a consistent deliberate absence, such as "no repository layer, controllers query Eloquent directly", so the next agent matches the app's altitude.
|
||||||
|
|
||||||
|
Then find what else makes this codebase itself: base or abstract classes most code extends, traits used everywhere, tenancy or authorization scoping woven through queries, naming schemes, and custom helpers. Same evidence bar, cite files. Record every genuine structural pattern, and cap the other house findings at ~5 so the pass stays high-signal.
|
||||||
|
|
||||||
|
Done when: every non-default `app/` directory from Step 0 has a verdict, and the pass has produced its cited house findings (or concluded there are none).
|
||||||
|
|
||||||
|
### Step 3: Confirm
|
||||||
|
|
||||||
|
Present every candidate in one batch. Per item: dimension, verdict, evidence (counts and files), and the exact proposed `glob` or `globs` / `title` / `note`. Conflicts are presented as questions about an existing context boundary or deferred cleanup, not as a choice of future style.
|
||||||
|
|
||||||
|
Default mode is confirm: record only what the user approves. Switch to yolo only when the invocation said so ("yolo", "don't ask", "just record them"), then record all pattern candidates without asking. Conflicts still go to the user in yolo.
|
||||||
|
|
||||||
|
Done when: every candidate is approved, rejected, or (conflicts) decided.
|
||||||
|
|
||||||
|
### Step 4: Record
|
||||||
|
|
||||||
|
Make one `record-rule` call for each glob an approved convention applies to. Choose the most specific globs that cover the cited evidence from the mapping table below; if a convention spans models and migrations, record it under both domains so agents discover it from either path. The `note` is the bare convention: strip every trace of detection (see the ground rule). If `record-rule` is unavailable (rules disabled), report the full rule text so the user can enable `BOOST_RULES_ENABLED` or add it by hand.
|
||||||
|
|
||||||
|
Record this:
|
||||||
|
|
||||||
|
> Accessors and mutators: use the legacy magic-method style (`getXxxAttribute()` / `setXxxAttribute()`), not the `Attribute` class. Match it in models.
|
||||||
|
|
||||||
|
Not this:
|
||||||
|
|
||||||
|
> Accessors/mutators use the legacy magic-method style; the `Attribute`-class style is not used anywhere (13 legacy, 0 Attribute-class), e.g. `app/Models/Post.php`. Match the legacy style in existing models.
|
||||||
|
|
||||||
|
Done when: every approved item has a successful tool response, and any failure is reported with its rule text.
|
||||||
|
|
||||||
|
### Step 5: Summarize
|
||||||
|
|
||||||
|
List recorded rules (file and title), conflicts the user deferred, notable no-signals, and remind the user to commit `.ai/rules` so their team and agents share the conventions.
|
||||||
|
|
||||||
|
## Glob mapping
|
||||||
|
|
||||||
|
Attach each rule to the most specific path that covers its evidence. Never a lazy `app/**` when a subtree fits. Match the glob to where the code actually lives, which is not the same in a default skeleton and in a modular or DDD layout. Use the Step 0 `app/` map to pick the real path.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- Models: `app/Models/**` in a default app, or `app/Modules/Blog/Models/**` / `src/Domain/Blog/**` in a modular one.
|
||||||
|
- Controllers, routing, validation, responses: `app/Http/**`, or `app/Modules/*/Http/**` when each module owns its HTTP layer.
|
||||||
|
- Actions, Services, DTOs: `app/Actions/**`, `app/Services/**`, `app/Data/**`, or the module path the app actually uses.
|
||||||
|
- Tests: `tests/**`.
|
||||||
|
- Migrations and database: `database/migrations/**`.
|
||||||
|
- Truly app-wide (rare, e.g. auth retrieval): `app/**`.
|
||||||
|
|
||||||
|
`record-rule` takes one glob. When a convention genuinely spans two domains (e.g. UUID keys touch models and migrations), call it once per domain with the same title and note; mentioning another path in the note does not make the rule discoverable there.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- Rules disabled or `record-rule` missing: detection is read-only, so Steps 0 to 3 still run, and recording falls back to the manual path in Step 4.
|
||||||
|
- Tiny or fresh app: most dimensions land on no-signal. Say so honestly ("not enough code to infer conventions yet") and record nothing.
|
||||||
|
- Huge app: each dimension is a bounded grep plus a handful of file reads. Sample representative files, do not read everything.
|
||||||
|
- Re-runs: reading `.ai/rules` in Step 0 makes re-runs incremental, so only new or undecided dimensions surface.
|
||||||
|
- Non-standard layout (modules, DDD): the open-ended pass catches the layout itself as convention #1. Adapt the globs in the mapping table to the observed paths.
|
||||||
137
.cursor/skills/infer-conventions/references/checklist.md
Normal file
137
.cursor/skills/infer-conventions/references/checklist.md
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
# Detection Checklist
|
||||||
|
|
||||||
|
Every dimension here is a genuine fork: Laravel offers two or more valid approaches, the app's choice changes what the next agent writes, and no active project tool can pick for you. Left out on purpose: pure formatting (Pint owns it), any form an installed and enabled Rector rule rewrites to one canonical shape (`$casts` to `casts()`, `$fillable` to attributes, pipe-string rules to arrays, named to anonymous migrations, `$signature` to `#[Signature]`), and framework defaults any agent writes unprompted (`ShouldQueue` jobs, relation return types, `HasFactory`).
|
||||||
|
|
||||||
|
Each item gives the fork, then a hint (a grep or dir to spot which side the app takes). Hints are only a start. Read the matched files, never record on a raw count. Apply the ground rules to every verdict: a consistent choice that is a default or a tool's target form is not a pattern. Rows tagged (architecture) are the highest-signal, so record presence and deliberate absence.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Validation & HTTP input
|
||||||
|
|
||||||
|
1. Validation entry point: inline `$request->validate()` vs Form Request classes vs `Validator::make()`.
|
||||||
|
- Hint: `ls app/Http/Requests`; grep `->validate(` / `Validator::make(` in `app/Http/Controllers`.
|
||||||
|
2. Custom rule location: invokable rule objects in `app/Rules` vs inline closures vs `Validator::extend()` in a provider. Rule objects are the default `make:rule` path, so record only if the app leans on closures or `Validator::extend` instead. "No rule objects" alone is just no-signal.
|
||||||
|
- Hint: `ls app/Rules`; grep `Validator::extend` in `app/Providers`.
|
||||||
|
3. Typed input retrieval: typed getters (`$request->string()`, `->integer()`, `->enum()`, `->date()`) vs raw `$request->input()` / dynamic properties.
|
||||||
|
- Hint: grep `->string(` / `->integer(` / `->enum(` vs `->input(` in `app/Http`.
|
||||||
|
4. Custom messages/attributes: `lang/*/validation.php` vs Form Request `messages()` / `attributes()` methods.
|
||||||
|
- Hint: `ls lang`; grep `function messages`, `function attributes` in `app/Http/Requests`.
|
||||||
|
|
||||||
|
## B. Controllers & routing
|
||||||
|
|
||||||
|
5. Controller shape: invokable single-action (`__invoke`) vs resource controllers vs plain multi-method.
|
||||||
|
- Hint: grep `__invoke` in controllers; `Route::resource` / `apiResource` vs verb routes.
|
||||||
|
6. Business-logic location (architecture): fat controllers vs delegated to Actions / Services / Jobs.
|
||||||
|
- Hint: read a few controller methods; `ls app/Actions app/Services`.
|
||||||
|
7. Route handler style: closures in `routes/*.php` vs controller classes.
|
||||||
|
- Hint: count `function ()` vs `::class` in `routes/web.php`, `routes/api.php`.
|
||||||
|
8. Middleware assignment: route/group `->middleware()` vs controller `HasMiddleware::middleware()` vs `#[Middleware]` attribute.
|
||||||
|
- Hint: grep `implements HasMiddleware`, `#[Middleware(` in controllers vs `->middleware(` in routes.
|
||||||
|
9. Route model binding: implicit (type-hinted models) vs explicit `Route::bind` vs manual `findOrFail`.
|
||||||
|
- Hint: typed model params in signatures vs `findOrFail(` in controllers; grep `Route::bind`.
|
||||||
|
10. Rate limiting: named `RateLimiter::for()` + `throttle:name` vs inline `throttle:60,1`.
|
||||||
|
- Hint: grep `RateLimiter::for` in providers vs `throttle:` in route files.
|
||||||
|
|
||||||
|
## C. Authorization
|
||||||
|
|
||||||
|
11. Authorization home: Gates (`Gate::define`) vs Policy classes in `app/Policies`.
|
||||||
|
- Hint: `ls app/Policies`; grep `Gate::define` in `app/Providers`.
|
||||||
|
12. Authorization call site: `$this->authorize()` / `Gate::authorize()` vs `$user->can()` vs `can` middleware vs `#[Authorize]` vs `@can` in Blade.
|
||||||
|
- Hint: grep `authorize(`, `->can(`, `middleware('can:`, `#[Authorize(`, `@can(`.
|
||||||
|
|
||||||
|
## D. Eloquent & models
|
||||||
|
|
||||||
|
13. Mass assignment: `$fillable` allow-list vs `$guarded` block-list.
|
||||||
|
- Hint: grep `protected $fillable` / `protected $guarded` in `app/Models`.
|
||||||
|
14. Accessors/mutators: modern `Attribute` class vs legacy `getXxxAttribute()` / `setXxxAttribute()`. Record a legacy hold, it goes against the tool's grain.
|
||||||
|
- Hint: grep `: Attribute` / `Attribute::make` vs `function get[A-Z].*Attribute` in `app/Models`.
|
||||||
|
15. Primary keys: auto-increment vs `HasUuids` vs `HasUlids`.
|
||||||
|
- Hint: grep `HasUuids` / `HasUlids` in `app/Models`; migration `id()` vs `uuid('id')`.
|
||||||
|
16. Custom casts: dedicated `CastsAttributes` classes (`app/Casts`) vs inline `Attribute` vs built-in cast strings.
|
||||||
|
- Hint: `ls app/Casts`; grep `Cast::class`, `AsStringable::class` in models.
|
||||||
|
17. Data/query layer (architecture): Eloquent directly in controllers vs repositories vs dedicated query objects (e.g. classes exposing `builder(): Builder`).
|
||||||
|
- Hint: `ls app/Repositories app/Queries`; see where non-trivial queries are built.
|
||||||
|
18. Query scopes: local `scope`/`#[Scope]` methods vs dedicated builder classes.
|
||||||
|
- Hint: grep `function scope` / `#[Scope]` in models; `ls app/*/Builders`.
|
||||||
|
19. Model events: observers (`app/Observers`, `#[ObservedBy]`) vs `booted()` closures vs event classes.
|
||||||
|
- Hint: `ls app/Observers`; grep `booted`, `::observe`, `#[ObservedBy]`.
|
||||||
|
20. Eager-load posture: explicit per-query `->with()` vs model-level `$with` defaults. Treat `preventLazyLoading()` separately as a development guard because it can complement either posture.
|
||||||
|
- Hint: grep `protected $with`, `->with(`, and separately `preventLazyLoading` in `app/`.
|
||||||
|
|
||||||
|
## E. Architecture & organization
|
||||||
|
|
||||||
|
21. Action/Service structure (architecture): Action classes (invoked via `handle` / `execute` / `__invoke`) vs service objects vs neither. Cross-check the Step 0 `app/` map: any `Actions`/`Services`/`Pipelines`/`Jobs`-as-actions folder is this pattern, so record how it is invoked.
|
||||||
|
- Hint: `ls app/` (the whole tree, not just `Actions`/`Services`); grep the invocation method in the folder you find.
|
||||||
|
22. DTOs (architecture): spatie/laravel-data vs plain readonly classes vs arrays everywhere.
|
||||||
|
- Hint: `ls app/Data`; grep `extends Data`, `readonly class` in `app/`.
|
||||||
|
23. Dependency acquisition: constructor/method injection vs `app()` / `resolve()` / `App::make()` service location.
|
||||||
|
- Hint: grep `app(` / `resolve(` / `::make(` in `app/` vs promoted constructor deps.
|
||||||
|
24. Decoupling: events + listeners vs direct service calls.
|
||||||
|
- Hint: `ls app/Events app/Listeners`; grep `event(`, `::dispatch(`.
|
||||||
|
25. Helper vs facade idiom: global helpers (`config()`, `auth()`, `response()`) vs facades (`Config::`, `Auth::`, `Response::`).
|
||||||
|
- Hint: ratio of `config(` vs `Config::` (etc.) across `app/`.
|
||||||
|
26. Namespace layout (architecture): default `app/` skeleton vs domain/module folders (`app/Domain/**`, modules).
|
||||||
|
- Hint: `ls app/`, look for `Domain/`, `Modules/`, bounded-context folders.
|
||||||
|
27. Enums: backed vs pure; case naming; where they live.
|
||||||
|
- Hint: `ls app/Enums`; grep `enum .*: string`, `enum .*: int`.
|
||||||
|
|
||||||
|
## F. Frontend & views
|
||||||
|
|
||||||
|
This app ships a frontend stack, so the items below apply.
|
||||||
|
|
||||||
|
28. Frontend stack: Blade+Livewire vs Inertia (Vue/React/Svelte) vs Blade-only / API + separate SPA.
|
||||||
|
- Hint: `composer.json` + `package.json`; `ls resources/js/pages`, `resources/views`.
|
||||||
|
29. Blade composition: class `<x-*>` components vs anonymous components (`@props`) vs `@include` partials.
|
||||||
|
- Hint: `ls app/View/Components`; grep `<x-`, `@include` in `resources/views`.
|
||||||
|
32. Localization: short keys (`lang/*/*.php` + `__('messages.welcome')`) vs JSON string keys (`lang/*.json` + `__('Full sentence')`).
|
||||||
|
- Hint: `ls lang`; grep dotted `__('` vs sentence keys.
|
||||||
|
|
||||||
|
## G. Database & migrations
|
||||||
|
|
||||||
|
33. Foreign keys: `foreignId()->constrained()` vs `foreignIdFor(Model::class)` vs manual `foreign()->references()->on()`.
|
||||||
|
- Hint: grep `foreignId(`, `foreignIdFor(`, `->foreign(` in `database/migrations`.
|
||||||
|
34. `down()` methods: real reverse logic vs omitted / one-way migrations.
|
||||||
|
- Hint: grep `function down` vs the migration count.
|
||||||
|
35. Enum storage: DB `enum()` column vs `string()` + PHP-enum cast on the model.
|
||||||
|
- Hint: grep `->enum(` in migrations vs string columns cast to enums.
|
||||||
|
36. Transactions: `DB::transaction(fn ...)` closure vs manual `beginTransaction` / `commit` / `rollBack`.
|
||||||
|
- Hint: grep `DB::transaction`, `beginTransaction` in `app/`.
|
||||||
|
37. Idempotent writes: `upsert` / `updateOrCreate` / `firstOrCreate` vs find-then-save.
|
||||||
|
- Hint: grep `upsert(`, `updateOrCreate(`, `firstOrCreate(` in `app/`.
|
||||||
|
|
||||||
|
## H. Testing
|
||||||
|
|
||||||
|
38. Framework: Pest (`it()` / `test()` / `expect()`) vs PHPUnit classes.
|
||||||
|
- Hint: `ls tests/Pest.php`; grep `it(` / `test(` vs `extends TestCase`.
|
||||||
|
39. DB reset: `RefreshDatabase` vs `DatabaseTruncation` vs `DatabaseMigrations`.
|
||||||
|
- Hint: grep those trait names in `tests/`.
|
||||||
|
40. Fixtures: compare how equivalent test-owned records are created, such as factories vs manual inserts. Track seeders separately for shared reference data because `$this->seed()` commonly and legitimately coexists with factories.
|
||||||
|
- Hint: grep `::factory(` and direct inserts in `tests/`; separately inspect `$this->seed(` calls and what those seeders provide.
|
||||||
|
41. Collaborator isolation: how the app doubles its own classes, Mockery `mock()` / `spy()` vs real integration. Ignore facade fakes like `Mail::fake()` here, they isolate framework services by default and are not a fork against Mockery.
|
||||||
|
- Hint: grep `->mock(`, `->spy(`, `Mockery::` in `tests/`.
|
||||||
|
42. Endpoint assertions: array `assertJson([...])` / `assertJsonFragment` vs fluent `AssertableJson`.
|
||||||
|
- Hint: grep `AssertableJson`, `assertJsonFragment` in `tests/`.
|
||||||
|
|
||||||
|
## I. Responses & API resources
|
||||||
|
|
||||||
|
43. Response shape: API Resource classes vs `response()->json()` vs returning models/arrays directly.
|
||||||
|
- Hint: `ls app/Http/Resources`; grep `JsonResource`, `->json(` in controllers.
|
||||||
|
44. Resource relationship inclusion: `whenLoaded()` guards vs unconditional relationship access. Do not count ordinary scalar attributes as rivals to conditional relationships, and evaluate general `when()` fields separately.
|
||||||
|
- Hint: compare relationship fields using `whenLoaded(` with unconditional relationship property access in `app/Http/Resources`.
|
||||||
|
45. Pagination contracts: within comparable endpoint categories, length-aware `paginate()` vs `simplePaginate()` vs `cursorPaginate()`. These have different totals, navigation, ordering, and performance contracts, so record only a stable path-scoped API policy, never a project-wide majority.
|
||||||
|
- Hint: grep those in `app/`, then group matches by endpoint type and client contract before comparing them.
|
||||||
|
46. Web redirects/URLs: `route('name')` vs `url('/path')` vs `action([...])`.
|
||||||
|
- Hint: grep `route('`, `url('/`, `action([` in `app/Http` and views.
|
||||||
|
|
||||||
|
## J. Strings, collections & dates
|
||||||
|
|
||||||
|
47. Iteration idiom: `collect()->map()->filter()` pipelines vs `array_map` / `foreach`.
|
||||||
|
- Hint: grep `collect(`, `->map(` vs `array_map`, `foreach` density in `app/`.
|
||||||
|
48. String API: fluent `Str::of()->...` (Stringable) vs static `Str::` vs native (`trim`, `strtoupper`).
|
||||||
|
- Hint: grep `Str::of(` vs `Str::` vs native string funcs.
|
||||||
|
49. Dates: compare equivalent construction call styles (`now()` / `today()` helpers vs `Carbon::`) separately from the application's mutable/immutable date policy. `Date::use(CarbonImmutable::class)` can make helpers return immutable dates, so those signals are complementary rather than conflicting.
|
||||||
|
- Hint: grep `now(` and `Carbon::` for call style; separately inspect `CarbonImmutable` and `Date::use` for mutability policy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Genuine forks only. Every row survived the "no tool can decide this, and it isn't the default" filter. Give each applicable dimension exactly one verdict: pattern, conflict, default, no-signal, tooling-owned, or already-recorded. The rows tagged (architecture) are where the highest-value rules come from.
|
||||||
|
|
@ -8,183 +8,52 @@
|
||||||
|
|
||||||
# Laravel Best Practices
|
# Laravel Best Practices
|
||||||
|
|
||||||
Best practices for Laravel, prioritized by impact. Each rule teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
Best practices for Laravel, organized as an index of rule files. Each rule file teaches what to do and why. For exact API syntax, verify with `search-docs`.
|
||||||
|
|
||||||
## Consistency First
|
## Consistency First
|
||||||
|
|
||||||
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches — the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
Before applying any rule, check what the application already does. Laravel offers multiple valid approaches, and the best choice is the one the codebase already uses, even if another pattern would be theoretically better. Inconsistency is worse than a suboptimal pattern.
|
||||||
|
|
||||||
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it — don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
Check sibling files, related controllers, models, or tests for established patterns. If one exists, follow it. Don't introduce a second way. These rules are defaults for when no pattern exists yet, not overrides.
|
||||||
|
|
||||||
## Quick Reference
|
|
||||||
|
|
||||||
### 1. Database Performance → `rules/db-performance.md`
|
|
||||||
|
|
||||||
- Eager load with `with()` to prevent N+1 queries
|
|
||||||
- Enable `Model::preventLazyLoading()` in development
|
|
||||||
- Select only needed columns, avoid `SELECT *`
|
|
||||||
- `chunk()` / `chunkById()` for large datasets
|
|
||||||
- Index columns used in `WHERE`, `ORDER BY`, `JOIN`
|
|
||||||
- `withCount()` instead of loading relations to count
|
|
||||||
- `cursor()` for memory-efficient read-only iteration
|
|
||||||
- Never query in Blade templates
|
|
||||||
|
|
||||||
### 2. Advanced Query Patterns → `rules/advanced-queries.md`
|
|
||||||
|
|
||||||
- `addSelect()` subqueries over eager-loading entire has-many for a single value
|
|
||||||
- Dynamic relationships via subquery FK + `belongsTo`
|
|
||||||
- Conditional aggregates (`CASE WHEN` in `selectRaw`) over multiple count queries
|
|
||||||
- `setRelation()` to prevent circular N+1 queries
|
|
||||||
- `whereIn` + `pluck()` over `whereHas` for better index usage
|
|
||||||
- Two simple queries can beat one complex query
|
|
||||||
- Compound indexes matching `orderBy` column order
|
|
||||||
- Correlated subqueries in `orderBy` for has-many sorting (avoid joins)
|
|
||||||
|
|
||||||
### 3. Security → `rules/security.md`
|
|
||||||
|
|
||||||
- Define `$fillable` or `$guarded` on every model, authorize every action via policies or gates
|
|
||||||
- No raw SQL with user input — use Eloquent or query builder
|
|
||||||
- `{{ }}` for output escaping, `@csrf` on all POST/PUT/DELETE forms, `throttle` on auth and API routes
|
|
||||||
- Validate MIME type, extension, and size for file uploads
|
|
||||||
- Never commit `.env`, use `config()` for secrets, `encrypted` cast for sensitive DB fields
|
|
||||||
|
|
||||||
### 4. Caching → `rules/caching.md`
|
|
||||||
|
|
||||||
- `Cache::remember()` over manual get/put
|
|
||||||
- `Cache::flexible()` for stale-while-revalidate on high-traffic data
|
|
||||||
- `Cache::memo()` to avoid redundant cache hits within a request
|
|
||||||
- Cache tags to invalidate related groups
|
|
||||||
- `Cache::add()` for atomic conditional writes
|
|
||||||
- `once()` to memoize per-request or per-object lifetime
|
|
||||||
- `Cache::lock()` / `lockForUpdate()` for race conditions
|
|
||||||
- Failover cache stores in production
|
|
||||||
|
|
||||||
### 5. Eloquent Patterns → `rules/eloquent.md`
|
|
||||||
|
|
||||||
- Correct relationship types with return type hints
|
|
||||||
- Local scopes for reusable query constraints
|
|
||||||
- Global scopes sparingly — document their existence
|
|
||||||
- Attribute casts in the `casts()` method
|
|
||||||
- Cast date columns, use Carbon instances in templates
|
|
||||||
- `whereBelongsTo($model)` for cleaner queries
|
|
||||||
- Never hardcode table names — use `(new Model)->getTable()` or Eloquent queries
|
|
||||||
|
|
||||||
### 6. Validation & Forms → `rules/validation.md`
|
|
||||||
|
|
||||||
- Form Request classes, not inline validation
|
|
||||||
- Array notation `['required', 'email']` for new code; follow existing convention
|
|
||||||
- `$request->validated()` only — never `$request->all()`
|
|
||||||
- `Rule::when()` for conditional validation
|
|
||||||
- `after()` instead of `withValidator()`
|
|
||||||
|
|
||||||
### 7. Configuration → `rules/config.md`
|
|
||||||
|
|
||||||
- `env()` only inside config files
|
|
||||||
- `App::environment()` or `app()->isProduction()`
|
|
||||||
- Config, lang files, and constants over hardcoded text
|
|
||||||
|
|
||||||
### 8. Testing Patterns → `rules/testing.md`
|
|
||||||
|
|
||||||
- `LazilyRefreshDatabase` over `RefreshDatabase` for speed
|
|
||||||
- `assertModelExists()` over raw `assertDatabaseHas()`
|
|
||||||
- Factory states and sequences over manual overrides
|
|
||||||
- Use fakes (`Event::fake()`, `Exceptions::fake()`, etc.) — but always after factory setup, not before
|
|
||||||
- `recycle()` to share relationship instances across factories
|
|
||||||
|
|
||||||
### 9. Queue & Job Patterns → `rules/queue-jobs.md`
|
|
||||||
|
|
||||||
- `retry_after` must exceed job `timeout`; use exponential backoff `[1, 5, 10]`
|
|
||||||
- `ShouldBeUnique` to prevent duplicates; `ShouldBeUniqueUntilProcessing` for early lock release
|
|
||||||
- Always implement `failed()`; with `retryUntil()`, set `$tries = 0`
|
|
||||||
- `RateLimited` middleware for external API calls; `Bus::batch()` for related jobs
|
|
||||||
- Horizon for complex multi-queue scenarios
|
|
||||||
|
|
||||||
### 10. Routing & Controllers → `rules/routing.md`
|
|
||||||
|
|
||||||
- Implicit route model binding
|
|
||||||
- Scoped bindings for nested resources
|
|
||||||
- `Route::resource()` or `apiResource()`
|
|
||||||
- Methods under 10 lines — extract to actions/services
|
|
||||||
- Type-hint Form Requests for auto-validation
|
|
||||||
|
|
||||||
### 11. HTTP Client → `rules/http-client.md`
|
|
||||||
|
|
||||||
- Explicit `timeout` and `connectTimeout` on every request
|
|
||||||
- `retry()` with exponential backoff for external APIs
|
|
||||||
- Check response status or use `throw()`
|
|
||||||
- `Http::pool()` for concurrent independent requests
|
|
||||||
- `Http::fake()` and `preventStrayRequests()` in tests
|
|
||||||
|
|
||||||
### 12. Events, Notifications & Mail → `rules/events-notifications.md`, `rules/mail.md`
|
|
||||||
|
|
||||||
- Event discovery over manual registration; `event:cache` in production
|
|
||||||
- `ShouldDispatchAfterCommit` / `afterCommit()` inside transactions
|
|
||||||
- Queue notifications and mailables with `ShouldQueue`
|
|
||||||
- On-demand notifications for non-user recipients
|
|
||||||
- `HasLocalePreference` on notifiable models
|
|
||||||
- `assertQueued()` not `assertSent()` for queued mailables
|
|
||||||
- Markdown mailables for transactional emails
|
|
||||||
|
|
||||||
### 13. Error Handling → `rules/error-handling.md`
|
|
||||||
|
|
||||||
- `report()`/`render()` on exception classes or in `bootstrap/app.php` — follow existing pattern
|
|
||||||
- `ShouldntReport` for exceptions that should never log
|
|
||||||
- Throttle high-volume exceptions to protect log sinks
|
|
||||||
- `dontReportDuplicates()` for multi-catch scenarios
|
|
||||||
- Force JSON rendering for API routes
|
|
||||||
- Structured context via `context()` on exception classes
|
|
||||||
|
|
||||||
### 14. Task Scheduling → `rules/scheduling.md`
|
|
||||||
|
|
||||||
- `withoutOverlapping()` on variable-duration tasks
|
|
||||||
- `onOneServer()` on multi-server deployments
|
|
||||||
- `runInBackground()` for concurrent long tasks
|
|
||||||
- `environments()` to restrict to appropriate environments
|
|
||||||
- `takeUntilTimeout()` for time-bounded processing
|
|
||||||
- Schedule groups for shared configuration
|
|
||||||
|
|
||||||
### 15. Architecture → `rules/architecture.md`
|
|
||||||
|
|
||||||
- Single-purpose Action classes; dependency injection over `app()` helper
|
|
||||||
- Prefer official Laravel packages and follow conventions, don't override defaults
|
|
||||||
- Default to `ORDER BY id DESC` or `created_at DESC`; `mb_*` for UTF-8 safety
|
|
||||||
- `defer()` for post-response work; `Context` for request-scoped data; `Concurrency::run()` for parallel execution
|
|
||||||
|
|
||||||
### 16. Migrations → `rules/migrations.md`
|
|
||||||
|
|
||||||
- Generate migrations with `php artisan make:migration`
|
|
||||||
- `constrained()` for foreign keys
|
|
||||||
- Never modify migrations that have run in production
|
|
||||||
- Add indexes in the migration, not as an afterthought
|
|
||||||
- Mirror column defaults in model `$attributes`
|
|
||||||
- Reversible `down()` by default; forward-fix migrations for intentionally irreversible changes
|
|
||||||
- One concern per migration — never mix DDL and DML
|
|
||||||
|
|
||||||
### 17. Collections → `rules/collections.md`
|
|
||||||
|
|
||||||
- Higher-order messages for simple collection operations
|
|
||||||
- `cursor()` vs. `lazy()` — choose based on relationship needs
|
|
||||||
- `lazyById()` when updating records while iterating
|
|
||||||
- `toQuery()` for bulk operations on collections
|
|
||||||
|
|
||||||
### 18. Blade & Views → `rules/blade-views.md`
|
|
||||||
|
|
||||||
- `$attributes->merge()` in component templates
|
|
||||||
- Blade components over `@include`; `@pushOnce` for per-component scripts
|
|
||||||
- View Composers for shared view data
|
|
||||||
- `@aware` for deeply nested component props
|
|
||||||
|
|
||||||
### 19. Conventions & Style → `rules/style.md`
|
|
||||||
|
|
||||||
- Follow Laravel naming conventions for all entities
|
|
||||||
- Prefer Laravel helpers (`Str`, `Arr`, `Number`, `Uri`, `Str::of()`, `$request->string()`) over raw PHP functions
|
|
||||||
- No JS/CSS in Blade, no HTML in PHP classes
|
|
||||||
- Code should be readable; comments only for config files
|
|
||||||
|
|
||||||
## How to Apply
|
## How to Apply
|
||||||
|
|
||||||
Always use a sub-agent to read rule files and explore this skill's content.
|
1. Check the changed files, nearby code, project configuration, and relevant tests for established patterns. Deviate only for a correctness or security defect, and call the deviation out.
|
||||||
|
2. Map every affected concern to the rule index below. Read each mapped rule file before editing. Skip unrelated rule files.
|
||||||
|
3. Make the smallest coherent change. Keep the application's architecture and naming instead of introducing a second pattern for the same job.
|
||||||
|
4. Verify version-sensitive Laravel APIs for the installed version with `search-docs`, or inspect the installed framework when it is unavailable.
|
||||||
|
5. Run the narrowest relevant tests first, then the project's formatting and static-analysis checks when the change warrants them.
|
||||||
|
6. Re-read the diff against every mapped rule before finishing.
|
||||||
|
|
||||||
1. Identify the file type and select relevant sections (e.g., migration → §16, controller → §1, §3, §5, §6, §10)
|
## Rule Index
|
||||||
2. Check sibling files for existing patterns — follow those first per Consistency First
|
|
||||||
3. Verify API syntax with `search-docs` for the installed Laravel version
|
Cross-cutting changes often need more than one rule file.
|
||||||
|
|
||||||
|
| Concern | Read |
|
||||||
|
| --- | --- |
|
||||||
|
| Query count, eager loading, indexes, large datasets | [`rules/db-performance.md`](rules/db-performance.md) |
|
||||||
|
| Subqueries, aggregates, complex ordering and query plans | [`rules/advanced-queries.md`](rules/advanced-queries.md) |
|
||||||
|
| Models, relationships, scopes, casts | [`rules/eloquent.md`](rules/eloquent.md) |
|
||||||
|
| Authentication, authorization, input safety, secrets, uploads | [`rules/security.md`](rules/security.md) |
|
||||||
|
| Form Requests and validation rules | [`rules/validation.md`](rules/validation.md) |
|
||||||
|
| Controllers, route binding, resources, middleware | [`rules/routing.md`](rules/routing.md) |
|
||||||
|
| Schema changes, columns, foreign keys, indexes | [`rules/migrations.md`](rules/migrations.md) |
|
||||||
|
| Jobs, retries, uniqueness, batches, Horizon | [`rules/queue-jobs.md`](rules/queue-jobs.md) |
|
||||||
|
| Cache lifetime, invalidation, locks, memoization | [`rules/caching.md`](rules/caching.md) |
|
||||||
|
| Outbound requests, retries, timeouts, fakes | [`rules/http-client.md`](rules/http-client.md) |
|
||||||
|
| Exceptions, reporting, rendering, log context | [`rules/error-handling.md`](rules/error-handling.md) |
|
||||||
|
| Events and notifications | [`rules/events-notifications.md`](rules/events-notifications.md) |
|
||||||
|
| Mailables and mail assertions | [`rules/mail.md`](rules/mail.md) |
|
||||||
|
| Scheduled tasks and overlap protection | [`rules/scheduling.md`](rules/scheduling.md) |
|
||||||
|
| Collections, lazy iteration, bulk operations | [`rules/collections.md`](rules/collections.md) |
|
||||||
|
| Blade components, attributes, composers | [`rules/blade-views.md`](rules/blade-views.md) |
|
||||||
|
| Environment values and application configuration | [`rules/config.md`](rules/config.md) |
|
||||||
|
| Pest/PHPUnit patterns, factories, fakes | [`rules/testing.md`](rules/testing.md) |
|
||||||
|
| Naming, helpers, file boundaries, PHP style | [`rules/style.md`](rules/style.md) |
|
||||||
|
| Actions, services, dependencies, application structure | [`rules/architecture.md`](rules/architecture.md) |
|
||||||
|
|
||||||
|
## Decision Rules
|
||||||
|
|
||||||
|
- Prefer framework features and existing application abstractions over new helpers or dependencies.
|
||||||
|
- Avoid speculative abstractions. Extract code when it creates a clear domain boundary, removes meaningful duplication, or makes behavior independently testable.
|
||||||
|
- Keep database access out of Blade views and prevent hidden N+1 queries across controllers, resources, jobs, and serialization.
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ ## Single-Purpose Action Classes
|
||||||
{
|
{
|
||||||
public function __construct(private InventoryService $inventory) {}
|
public function __construct(private InventoryService $inventory) {}
|
||||||
|
|
||||||
public function execute(array $data): Order
|
public function handle(array $data): Order
|
||||||
{
|
{
|
||||||
$order = Order::create($data);
|
$order = Order::create($data);
|
||||||
$this->inventory->reserve($order);
|
$this->inventory->reserve($order);
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,8 @@ ## Use Local Scopes for Reusable Queries
|
||||||
|
|
||||||
Correct:
|
Correct:
|
||||||
```php
|
```php
|
||||||
public function scopeActive(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function active(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('verified', true)->whereNotNull('activated_at');
|
return $query->where('verified', true)->whereNotNull('activated_at');
|
||||||
}
|
}
|
||||||
|
|
@ -58,7 +59,8 @@ ## Apply Global Scopes Sparingly
|
||||||
|
|
||||||
Correct (local scope you opt into):
|
Correct (local scope you opt into):
|
||||||
```php
|
```php
|
||||||
public function scopePublished(Builder $query): Builder
|
#[Scope]
|
||||||
|
protected function published(Builder $query): Builder
|
||||||
{
|
{
|
||||||
return $query->where('published', true);
|
return $query->where('published', true);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -44,7 +44,7 @@ ## Use Laravel String & Array Helpers
|
||||||
// Incorrect
|
// Incorrect
|
||||||
$slug = strtolower(str_replace(' ', '-', $title));
|
$slug = strtolower(str_replace(' ', '-', $title));
|
||||||
$short = substr($text, 0, 100) . '...';
|
$short = substr($text, 0, 100) . '...';
|
||||||
$class = substr(strrchr('App\Models\User', '\'), 1);
|
$class = substr(strrchr('App\Models\User', '\\'), 1);
|
||||||
|
|
||||||
// Correct
|
// Correct
|
||||||
$slug = Str::slug($title);
|
$slug = Str::slug($title);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: mcp-development
|
name: mcp-development
|
||||||
description: "Use this skill for Laravel MCP development only. Trigger when creating or editing MCP tools, resources, prompts, or servers in Laravel projects. Covers: artisan make:mcp-* generators, mcp:inspector, routes/ai.php, Tool/Resource/Prompt classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, and MCP debugging. Do not use for non-Laravel MCP projects or generic AI features without MCP."
|
description: "Use this skill for Laravel MCP development. Trigger when creating or editing MCP tools, resources, prompts, servers, or UI apps in Laravel projects. Covers: artisan make:mcp-* generators, routes/ai.php, Tool/Resource/Prompt/AppResource classes, schema validation, shouldRegister(), OAuth setup, URI templates, read-only attributes, MCP debugging, MCP UI apps, the x-mcp::app Blade component, createMcpApp(), default AppResource handle() auto-infers view from class name, Response::view(), AppMeta/Csp/Permissions/appMeta() configuration, #[RendersApp] attribute, Library enum for CDN libraries (Tailwind, Alpine), and host theming via CSS variables. Use this whenever the user mentions MCP apps, MCP UI, interactive MCP resources, styling MCP apps with Tailwind or Alpine, or building visual interfaces for AI agents."
|
||||||
license: MIT
|
license: MIT
|
||||||
metadata:
|
metadata:
|
||||||
author: laravel
|
author: laravel
|
||||||
|
|
@ -12,6 +12,8 @@ ## Documentation
|
||||||
|
|
||||||
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
Use `search-docs` for detailed Laravel MCP patterns and documentation.
|
||||||
|
|
||||||
|
For MCP UI apps (interactive HTML resources), read `references/app.md` — it covers the full architecture, host theming CSS variables, tool-to-UI linking patterns, library scripts (Tailwind, Alpine via `Library`), and real-world examples.
|
||||||
|
|
||||||
## Basic Usage
|
## Basic Usage
|
||||||
|
|
||||||
Register MCP servers in `routes/ai.php`:
|
Register MCP servers in `routes/ai.php`:
|
||||||
|
|
@ -25,8 +27,6 @@ ## Basic Usage
|
||||||
|
|
||||||
### Creating MCP Primitives
|
### Creating MCP Primitives
|
||||||
|
|
||||||
Create MCP tools, resources, prompts, and servers using artisan commands:
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php artisan make:mcp-tool ToolName # Create a tool
|
php artisan make:mcp-tool ToolName # Create a tool
|
||||||
|
|
||||||
|
|
@ -36,6 +36,8 @@ ### Creating MCP Primitives
|
||||||
|
|
||||||
php artisan make:mcp-server ServerName # Create a server
|
php artisan make:mcp-server ServerName # Create a server
|
||||||
|
|
||||||
|
php artisan make:mcp-app-resource DashboardApp # Create a UI app (2 files)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
|
||||||
|
|
@ -44,23 +46,33 @@ ### Tools
|
||||||
|
|
||||||
<!-- MCP Tool Example -->
|
<!-- MCP Tool Example -->
|
||||||
```php
|
```php
|
||||||
|
use Illuminate\Json\Schema\JsonSchema;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\Server\Tool;
|
use Laravel\Mcp\Server\Tool;
|
||||||
use Laravel\Mcp\Server\Request;
|
|
||||||
use Laravel\Mcp\Server\Response;
|
|
||||||
|
|
||||||
class MyTool extends Tool
|
class MyTool extends Tool
|
||||||
{
|
{
|
||||||
|
protected string $description = 'Describe what this tool does';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'name' => $schema->string()->description('The name parameter')->required(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
public function handle(Request $request): Response
|
public function handle(Request $request): Response
|
||||||
{
|
{
|
||||||
return new Response(['result' => 'success']);
|
$request->validate(['name' => 'required|string']);
|
||||||
|
|
||||||
|
return Response::text('Hello, '.$request->get('name'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
### Registering Primitives in a Server
|
### Registering Primitives in a Server
|
||||||
|
|
||||||
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
|
|
||||||
|
|
||||||
<!-- Register Primitives in MCP Server -->
|
<!-- Register Primitives in MCP Server -->
|
||||||
```php
|
```php
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
|
|
@ -81,6 +93,10 @@ ### Registering Primitives in a Server
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## MCP UI Apps
|
||||||
|
|
||||||
|
For MCP UI apps, read `references/app.md` — it covers quick start examples, full architecture, AppMeta/Csp/Permissions, `#[RendersApp]` tool linking, library scripts (Tailwind/Alpine via `Library`), host theming CSS variables, and real-world patterns.
|
||||||
|
|
||||||
## Verification
|
## Verification
|
||||||
|
|
||||||
1. Check `routes/ai.php` for proper registration
|
1. Check `routes/ai.php` for proper registration
|
||||||
|
|
@ -92,5 +108,5 @@ ## Common Pitfalls
|
||||||
- Using HTTPS locally with Node-based MCP clients
|
- Using HTTPS locally with Node-based MCP clients
|
||||||
- Not using `search-docs` for the latest MCP documentation
|
- Not using `search-docs` for the latest MCP documentation
|
||||||
- Not registering MCP server routes in `routes/ai.php`
|
- Not registering MCP server routes in `routes/ai.php`
|
||||||
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.
|
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically
|
||||||
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
- OAuth registration supports custom URI schemes (e.g., `cursor://`, `vscode://`) for native desktop clients via `mcp.custom_schemes` config
|
||||||
|
|
|
||||||
940
.cursor/skills/mcp-development/references/app.md
Normal file
940
.cursor/skills/mcp-development/references/app.md
Normal file
|
|
@ -0,0 +1,940 @@
|
||||||
|
# MCP UI Apps Reference
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
`make:mcp-app-resource DashboardApp` generates two files — a PHP registration stub and a Blade view. The entire app lives in the Blade view.
|
||||||
|
|
||||||
|
**PHP class** — renders the Blade view. The view name is auto-inferred from the class name (`mcp.<kebab-class-name>`), so the generated stub needs no changes unless you're passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Blade view** — HTML structure + inline JS, everything in one file:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<h1>Dashboard App</h1>
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
`createMcpApp` is a global pre-bundled by the package — no npm install, no imports, no Vite required. It handles connection, error handling, and host theming automatically.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Concept: Tool + Resource
|
||||||
|
|
||||||
|
Every MCP App is built from two parts linked together:
|
||||||
|
|
||||||
|
- **Tool** — called by the LLM or host. Returns a text/data response and tells the host which UI resource to render via `_meta.ui.resourceUri`.
|
||||||
|
- **AppResource** — serves the self-contained HTML app. The host fetches it after the tool is called and renders it in a sandboxed iframe.
|
||||||
|
|
||||||
|
```
|
||||||
|
LLM calls Tool
|
||||||
|
└─► Tool response includes _meta.ui.resourceUri → "ui://dashboard-app"
|
||||||
|
└─► Host fetches AppResource at that URI
|
||||||
|
└─► Host renders HTML in sandboxed iframe
|
||||||
|
└─► createMcpApp() connects the iframe back to the server
|
||||||
|
└─► UI calls app-only tools to load/refresh data
|
||||||
|
```
|
||||||
|
|
||||||
|
The link is declared once with `#[RendersApp]` on the tool:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
After that, the host handles fetching and rendering the resource automatically — you never reference the URI by hand.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Overview
|
||||||
|
|
||||||
|
MCP Apps add interactive UI to the Model Context Protocol. The server returns self-contained HTML with all JS/CSS inlined. The host renders it in a sandboxed iframe. Apps communicate back via `createMcpApp()` — a pre-bundled global implementing the MCP UI PostMessage protocol.
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
│ Host (Claude, ChatGPT, VS Code) │
|
||||||
|
│ ┌───────────────────────────────────────┐ │
|
||||||
|
│ │ Sandboxed iframe │ │
|
||||||
|
│ │ ┌─────────────────────────────────┐ │ │
|
||||||
|
│ │ │ Your MCP App (HTML/JS/CSS) │ │ │
|
||||||
|
│ │ │ - Rendered by AppResource │ │ │
|
||||||
|
│ │ │ - Single self-contained HTML │ │ │
|
||||||
|
│ │ │ - Themed via host CSS vars │ │ │
|
||||||
|
│ │ └─────────────────────────────────┘ │ │
|
||||||
|
│ └───────────────────────────────────────┘ │
|
||||||
|
└──────────────────┬──────────────────────────┘
|
||||||
|
│ MCP Protocol (JSON-RPC)
|
||||||
|
┌──────────────────▼──────────────────────────┐
|
||||||
|
│ Laravel MCP Server │
|
||||||
|
│ - AppResource → self-contained HTML │
|
||||||
|
│ - Tool #[RendersApp] → triggers UI display │
|
||||||
|
│ - resources/read → serves HTML + _meta.ui │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
The server automatically advertises `io.modelcontextprotocol/ui` capability when any `AppResource` is registered. The client declares support in `capabilities.extensions["io.modelcontextprotocol/ui"]` during the initialize handshake.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server-Side
|
||||||
|
|
||||||
|
Minimal case — `handle()` renders the Blade view, entire app lives there:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class DashboardApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.dashboard-app', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Auto-renders `resources/views/mcp/dashboard-app.blade.php` with `$title` available via `$this->title()`.
|
||||||
|
|
||||||
|
Override `handle()` only when passing additional server-side data:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class AnalyticsDashboard extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::view('mcp.analytics-dashboard', [
|
||||||
|
'title' => $this->title(),
|
||||||
|
'metrics' => Metric::latest()->take(10)->get(),
|
||||||
|
'totalUsers' => User::count(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`Response::view($view, $data = [], $mergeData = [])` renders a Blade view and returns it as text.
|
||||||
|
|
||||||
|
`Response::html($path)` reads an HTML file from disk and returns its content. Relative paths resolve via `resource_path()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class StaticApp extends AppResource
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::html('mcp/static-app.html');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### AppMeta Configuration
|
||||||
|
|
||||||
|
The simplest way to configure UI metadata is via the `#[AppMeta]` attribute directly on your resource class:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\AppMeta;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
#[AppMeta(
|
||||||
|
connectDomains: ['https://api.stripe.com'],
|
||||||
|
permissions: [Permission::Camera, Permission::ClipboardWrite],
|
||||||
|
prefersBorder: true,
|
||||||
|
libraries: [Library::Tailwind, Library::Alpine],
|
||||||
|
)]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
For dynamic or computed configuration, override `appMeta()` instead:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\AppMeta;
|
||||||
|
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains(config('services.api.domains')))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind)
|
||||||
|
->domain('sandbox.example.com');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permission Enum
|
||||||
|
|
||||||
|
Use the `Permission` enum for type-safe permission configuration:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Permission;
|
||||||
|
|
||||||
|
Permission::Camera // 'camera'
|
||||||
|
Permission::Microphone // 'microphone'
|
||||||
|
Permission::Geolocation // 'geolocation'
|
||||||
|
Permission::ClipboardWrite // 'clipboardWrite'
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Csp
|
||||||
|
|
||||||
|
Controls what external domains the iframe can access:
|
||||||
|
|
||||||
|
```php
|
||||||
|
Csp::make()
|
||||||
|
->connectDomains(['https://api.example.com']) // fetch, XHR, WebSocket origins
|
||||||
|
->resourceDomains(['https://cdn.example.com']) // images, scripts, fonts, media
|
||||||
|
->frameDomains(['https://embed.example.com']) // nested iframe origins
|
||||||
|
->baseUriDomains(['https://base.example.com']); // base URI origins
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Permissions
|
||||||
|
|
||||||
|
```php
|
||||||
|
Permissions::make()->allow(Permission::Camera, Permission::ClipboardWrite);
|
||||||
|
|
||||||
|
Permissions::make()
|
||||||
|
->camera()
|
||||||
|
->microphone()
|
||||||
|
->geolocation()
|
||||||
|
->clipboardWrite();
|
||||||
|
```
|
||||||
|
|
||||||
|
Each enabled permission serializes as `"camera": {}` per the MCP spec.
|
||||||
|
|
||||||
|
#### AppMeta
|
||||||
|
|
||||||
|
```php
|
||||||
|
AppMeta::make()
|
||||||
|
->csp(Csp::make()->connectDomains([...]))
|
||||||
|
->permissions(Permissions::make()->allow(Permission::Camera))
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine)
|
||||||
|
->domain('sandbox.example.com') // dedicated sandbox origin (OAuth/CORS)
|
||||||
|
->prefersBorder(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
`prefersBorder` defaults to `true`. `toArray()` omits null fields and empty nested objects. Library CDN domains are automatically merged into `csp.resourceDomains`.
|
||||||
|
|
||||||
|
#### domain
|
||||||
|
|
||||||
|
The `domain` field provides a stable origin that external APIs can allowlist for CORS. It is automatically resolved from `config('app.url')` (your `APP_URL` env variable) via `resolvedAppMeta()`, so most apps need no configuration. Override only when a resource needs a different origin:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(domain: 'custom.example.com')]
|
||||||
|
class PaymentsResource extends AppResource
|
||||||
|
{
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Library Scripts
|
||||||
|
|
||||||
|
The `libraries` parameter adds pre-configured CDN scripts to the `<head>` of your app. Available libraries:
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Library;
|
||||||
|
|
||||||
|
Library::Tailwind // Tailwind CSS CDN + dark mode config
|
||||||
|
Library::Alpine // Alpine.js CDN + x-cloak style
|
||||||
|
```
|
||||||
|
|
||||||
|
When libraries are specified, the package automatically:
|
||||||
|
|
||||||
|
1. Injects the CDN `<script>` tags into the Blade view's `<head>` (after the MCP SDK, before your `<x-slot:head>`)
|
||||||
|
2. Merges each library's CDN domains into `csp.resourceDomains` so the host allows loading them
|
||||||
|
|
||||||
|
Via attribute:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[AppMeta(libraries: [Library::Tailwind])]
|
||||||
|
class StyledApp extends AppResource
|
||||||
|
{
|
||||||
|
// Tailwind is available in the Blade view — no extra setup
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Via fluent builder:
|
||||||
|
|
||||||
|
```php
|
||||||
|
public function appMeta(): AppMeta
|
||||||
|
{
|
||||||
|
return AppMeta::make()
|
||||||
|
->libraries(Library::Tailwind, Library::Alpine);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## View Layer
|
||||||
|
|
||||||
|
### `<x-mcp::app>` Blade Component
|
||||||
|
|
||||||
|
Renders a complete self-contained HTML document with the MCP SDK inlined. `createMcpApp` is available globally.
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<x-mcp::app title="Dashboard App">
|
||||||
|
<x-slot:head>
|
||||||
|
<script type="module">
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById('run-btn').addEventListener('click', async () => {
|
||||||
|
const result = await app.callServerTool({ name: 'tool-name', arguments: {} });
|
||||||
|
document.getElementById('output').textContent = result.content[0]?.text ?? '';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</x-slot:head>
|
||||||
|
|
||||||
|
<div id="app">
|
||||||
|
<button id="run-btn">Run</button>
|
||||||
|
<p id="output"></p>
|
||||||
|
</div>
|
||||||
|
</x-mcp::app>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props and slots:**
|
||||||
|
|
||||||
|
| Name | Type | Description |
|
||||||
|
| ------------- | ------------- | ---------------------------------------------------- |
|
||||||
|
| `title` | Prop | Sets `<title>`. Optional. |
|
||||||
|
| `head` | Named slot | Injected into `<head>` after the inlined SDK script. |
|
||||||
|
| Default slot | Slot | Body content. |
|
||||||
|
| `$attributes` | Attribute bag | Forwarded to `<body>` (e.g. `class="dark"`). |
|
||||||
|
|
||||||
|
The SDK is loaded from the `mcp.sdk` singleton (registered by `McpServiceProvider`) and inlined directly in a `<script>` tag. Library scripts (Tailwind, Alpine) configured via `#[AppMeta]` are injected after the SDK and before the `head` slot.
|
||||||
|
|
||||||
|
Publish the component: `php artisan vendor:publish --tag=mcp-views`.
|
||||||
|
|
||||||
|
To pass server-side data to JS, embed it as `data-*` attributes:
|
||||||
|
|
||||||
|
```blade
|
||||||
|
<div id="app" data-users="{{ $users->toJson() }}">
|
||||||
|
...
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
const users = JSON.parse(document.getElementById("app").dataset.users);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Client-Side
|
||||||
|
|
||||||
|
This package provides a simple MCP client library to easily work with client interactions.
|
||||||
|
|
||||||
|
### createMcpApp
|
||||||
|
|
||||||
|
Pre-bundled and inlined automatically — no npm install or imports required.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
// app is ready — connection established, theming applied
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tools
|
||||||
|
|
||||||
|
#### app.callServerTool()
|
||||||
|
|
||||||
|
Accepts an object or positional arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form
|
||||||
|
const result = await app.callServerTool({ name: 'get-analytics', arguments: { dateRange: '7d' } });
|
||||||
|
|
||||||
|
// Positional form
|
||||||
|
const result = await app.callServerTool('get-analytics', { dateRange: '7d' });
|
||||||
|
|
||||||
|
// result structure depends on the server's tool response
|
||||||
|
const text = result.content[0]?.text ?? "";
|
||||||
|
```
|
||||||
|
|
||||||
|
All tool results share a standard structure:
|
||||||
|
|
||||||
|
| Property | Type | Description |
|
||||||
|
| --------- | --------- | ------------------------------------------------------------------------- |
|
||||||
|
| `content` | `Array` | Content items returned by the tool (each has `type` and `text` or `data`) |
|
||||||
|
| `isError` | `boolean` | `true` when the tool returned an error response |
|
||||||
|
|
||||||
|
Always check `result.isError` before consuming `content`. See [Error Handling](#error-handling) for a full example.
|
||||||
|
|
||||||
|
### Resources
|
||||||
|
|
||||||
|
#### app.listResources()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resources = await app.listResources();
|
||||||
|
// or with cursor for pagination
|
||||||
|
const resources = await app.listResources("cursor-value");
|
||||||
|
// or object form
|
||||||
|
const resources = await app.listResources({ cursor: "cursor-value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.readResource()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const resource = await app.readResource("ui://my-resource");
|
||||||
|
// or object form
|
||||||
|
const resource = await app.readResource({ uri: "ui://my-resource" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
#### app.sendMessage()
|
||||||
|
|
||||||
|
Send a message to the model (creates a conversation turn):
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Object form with structured content
|
||||||
|
await app.sendMessage({
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "text", text: "User submitted the form." }],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Shorthand — plain string content with optional role (defaults to 'user')
|
||||||
|
await app.sendMessage("User submitted the form.");
|
||||||
|
await app.sendMessage("System event occurred.", "user");
|
||||||
|
```
|
||||||
|
|
||||||
|
### Host Context
|
||||||
|
|
||||||
|
#### app.getHostContext()
|
||||||
|
|
||||||
|
Returns the current host context, including theme and style variables:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const ctx = app.getHostContext();
|
||||||
|
ctx?.theme; // 'light' | 'dark'
|
||||||
|
ctx?.styles?.variables; // CSS variable map from host
|
||||||
|
ctx?.styles?.css?.fonts; // font CSS from host
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostInfo()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const info = app.getHostInfo();
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.getHostCapabilities()
|
||||||
|
|
||||||
|
```js
|
||||||
|
const caps = app.getHostCapabilities();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation & Files
|
||||||
|
|
||||||
|
#### app.openLink()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.openLink("https://example.com");
|
||||||
|
// or object form
|
||||||
|
await app.openLink({ url: "https://example.com" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.downloadFile()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.downloadFile("file contents here");
|
||||||
|
// or object form
|
||||||
|
await app.downloadFile({ contents: "file contents here" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Display
|
||||||
|
|
||||||
|
#### app.requestDisplayMode()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.requestDisplayMode("fullscreen");
|
||||||
|
// or object form
|
||||||
|
await app.requestDisplayMode({ mode: "fullscreen" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### app.resize() / app.autoResize()
|
||||||
|
|
||||||
|
`resize()` sends a one-time size notification. `autoResize()` uses `ResizeObserver` to continuously notify the host of size changes. It returns a cleanup function that disconnects the observer — useful if you need to stop observing before teardown. The observer is also automatically disconnected on teardown.
|
||||||
|
|
||||||
|
```js
|
||||||
|
const stopObserving = app.autoResize();
|
||||||
|
|
||||||
|
// Later, if needed:
|
||||||
|
stopObserving();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context
|
||||||
|
|
||||||
|
#### app.updateModelContext()
|
||||||
|
|
||||||
|
```js
|
||||||
|
await app.updateModelContext({ key: "value" });
|
||||||
|
```
|
||||||
|
|
||||||
|
### Lifecycle
|
||||||
|
|
||||||
|
#### app.requestTeardown()
|
||||||
|
|
||||||
|
Sends a teardown notification to the host.
|
||||||
|
|
||||||
|
```js
|
||||||
|
app.requestTeardown();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Logging
|
||||||
|
|
||||||
|
#### app.sendLog()
|
||||||
|
|
||||||
|
```js
|
||||||
|
// Positional form
|
||||||
|
await app.sendLog("info", "Processing started", "my-logger");
|
||||||
|
|
||||||
|
// Object form
|
||||||
|
await app.sendLog({
|
||||||
|
level: "info",
|
||||||
|
data: "Processing started",
|
||||||
|
logger: "my-logger",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Event Handlers
|
||||||
|
|
||||||
|
Register callbacks for host-side events. Tool input/result/cancelled events are queued until a handler is registered, then flushed.
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInput((params) => {
|
||||||
|
/* tool input received */
|
||||||
|
});
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
/* partial tool input */
|
||||||
|
});
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
/* tool result received */
|
||||||
|
});
|
||||||
|
app.onToolCancelled((params) => {
|
||||||
|
/* tool was cancelled */
|
||||||
|
});
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
/* theme/styles changed */
|
||||||
|
});
|
||||||
|
app.onTeardown(async () => {
|
||||||
|
/* cleanup before teardown */
|
||||||
|
});
|
||||||
|
app.onCallTool(async (params) => {
|
||||||
|
/* host requests tool call */
|
||||||
|
});
|
||||||
|
app.onListTools(async (params) => {
|
||||||
|
/* host requests tool list */
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Host Theming
|
||||||
|
|
||||||
|
`createMcpApp` automatically applies host theming on connect and on context change:
|
||||||
|
|
||||||
|
- Sets `data-theme` attribute and `color-scheme` on `<html>`
|
||||||
|
- Applies CSS variables from `hostContext.styles.variables` to `:root`
|
||||||
|
- Injects font CSS from `hostContext.styles.css.fonts` into a `<style>` tag
|
||||||
|
|
||||||
|
The specific CSS variables available depend on the host. Always provide fallback values — use `light-dark()` for theme-aware defaults:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--color-background-primary: light-dark(#ffffff, #171717);
|
||||||
|
--color-text-primary: light-dark(#171717, #fafafa);
|
||||||
|
--color-text-secondary: light-dark(#525252, #a3a3a3);
|
||||||
|
--color-border-primary: light-dark(#e5e5e5, #404040);
|
||||||
|
--font-sans: system-ui, -apple-system, sans-serif;
|
||||||
|
--border-radius-md: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
background: var(--color-background-primary);
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
background: var(--color-background-secondary);
|
||||||
|
border: 1px solid var(--color-border-primary);
|
||||||
|
border-radius: var(--border-radius-md);
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tool-to-UI Linking
|
||||||
|
|
||||||
|
### #[RendersApp] Attribute
|
||||||
|
|
||||||
|
Associates a Tool with a UI Resource. When the tool is called, the host fetches and renders the linked resource.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Laravel\Mcp\Server\Attributes\RendersApp;
|
||||||
|
use Laravel\Mcp\Server\Ui\Enums\Visibility;
|
||||||
|
|
||||||
|
// Both model and app can call this tool (default)
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool { ... }
|
||||||
|
|
||||||
|
// Only the app can call this tool (private to the UI)
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class RefreshDashboardData extends Tool { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
**Visibility:**
|
||||||
|
|
||||||
|
The `Visibility` enum (`Laravel\Mcp\Server\Ui\Enums\Visibility`) has two cases: `Model` and `App`. The default is `[Visibility::Model, Visibility::App]`.
|
||||||
|
|
||||||
|
| Visibility | Model | App | Use case |
|
||||||
|
| -------------------------------------- | ----- | --- | ------------------------------------------------------ |
|
||||||
|
| `[Visibility::Model, Visibility::App]` | Yes | Yes | Primary tools that trigger UI display |
|
||||||
|
| `[Visibility::App]` | No | Yes | Backend actions the UI calls (refresh, save, paginate) |
|
||||||
|
| `[Visibility::Model]` | Yes | No | Model-only tools linked to a UI |
|
||||||
|
|
||||||
|
### Primary + Private Pattern
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: DashboardApp::class)]
|
||||||
|
class ShowDashboard extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::text('Dashboard loaded.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[RendersApp(resource: DashboardApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetDashboardMetrics extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json(Metric::latest()->take(50)->get());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```php
|
||||||
|
it('returns html content', function () {
|
||||||
|
MyServer::readResource(DashboardApp::class)
|
||||||
|
->assertSee('<div id="app">');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has correct mime type and uri scheme', function () {
|
||||||
|
$resource = new DashboardApp;
|
||||||
|
$data = $resource->toArray();
|
||||||
|
|
||||||
|
expect($data['mimeType'])->toBe('text/html;profile=mcp-app')
|
||||||
|
->and($data['_meta']['ui'])->toBeArray()
|
||||||
|
->and($resource->uri())->toStartWith('ui://');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('configures ui meta correctly', function () {
|
||||||
|
$meta = (new DashboardApp)->resolvedAppMeta();
|
||||||
|
|
||||||
|
expect($meta['csp']['connectDomains'])->toContain('https://api.example.com')
|
||||||
|
->and($meta['permissions'])->toHaveKey('clipboardWrite');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes ui metadata in tool listing', function () {
|
||||||
|
MyServer::listTools()->assertSee('show-dashboard');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns
|
||||||
|
|
||||||
|
### Real-time Polling
|
||||||
|
|
||||||
|
Use app-only tools to fetch fresh data at regular intervals from the UI:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: MonitorApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetMonitorData extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch latest monitor metrics';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
return Response::json([
|
||||||
|
'cpu' => sys_getloadavg()[0],
|
||||||
|
'memory' => memory_get_usage(true),
|
||||||
|
'timestamp' => now()->toISOString(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function poll() {
|
||||||
|
const result = await app.callServerTool('get-monitor-data');
|
||||||
|
const data = JSON.parse(result.content[0]?.text ?? '{}');
|
||||||
|
document.getElementById('cpu').textContent = data.cpu;
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(poll, 2000);
|
||||||
|
poll();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Chunked Data Loading
|
||||||
|
|
||||||
|
For large datasets, implement pagination via app-only tools:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: LogViewerApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetLogChunk extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch a chunk of log entries';
|
||||||
|
|
||||||
|
public function schema(JsonSchema $schema): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'offset' => $schema->integer()->description('Byte offset to start from')->required(),
|
||||||
|
'limit' => $schema->integer()->description('Max bytes to return'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['offset' => 'required|integer', 'limit' => 'integer']);
|
||||||
|
|
||||||
|
$offset = $request->get('offset');
|
||||||
|
$limit = $request->get('limit', 500_000);
|
||||||
|
$content = Storage::get('logs/app.log');
|
||||||
|
$chunk = substr($content, $offset, $limit);
|
||||||
|
|
||||||
|
return Response::json([
|
||||||
|
'data' => $chunk,
|
||||||
|
'offset' => $offset,
|
||||||
|
'totalBytes' => strlen($content),
|
||||||
|
'hasMore' => ($offset + $limit) < strlen($content),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Binary Resource Serving
|
||||||
|
|
||||||
|
Deliver images and binary content through MCP resources using `Response::blob()`:
|
||||||
|
|
||||||
|
```php
|
||||||
|
#[RendersApp(resource: GalleryApp::class, visibility: [Visibility::App])]
|
||||||
|
class GetImage extends Tool
|
||||||
|
{
|
||||||
|
protected string $description = 'Fetch an image by ID';
|
||||||
|
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['id' => 'required|integer']);
|
||||||
|
|
||||||
|
$image = Image::findOrFail($request->get('id'));
|
||||||
|
$data = base64_encode(Storage::get($image->path));
|
||||||
|
|
||||||
|
return Response::blob($data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
In the client, convert the base64 blob to a data URI for rendering:
|
||||||
|
|
||||||
|
```js
|
||||||
|
const result = await app.callServerTool('get-image', { id: 42 });
|
||||||
|
const blob = result.content[0];
|
||||||
|
img.src = `data:${blob.mimeType};base64,${blob.data}`;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Streaming Argument Previews
|
||||||
|
|
||||||
|
Use `onToolInputPartial` to show previews as the model streams tool arguments:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
app.onToolInputPartial((params) => {
|
||||||
|
try {
|
||||||
|
const partial = JSON.parse(params.arguments);
|
||||||
|
if (partial.query) {
|
||||||
|
document.getElementById("preview").textContent = partial.query;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// partial JSON — ignore until parseable
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onToolResult((params) => {
|
||||||
|
const data = JSON.parse(params.result.content[0]?.text ?? "{}");
|
||||||
|
renderResults(data);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### View State Persistence
|
||||||
|
|
||||||
|
Use `localStorage` to preserve UI state across re-renders. For important state, persist server-side via an app-only tool:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const STATE_KEY = "dashboard-view-state";
|
||||||
|
|
||||||
|
// Restore from localStorage
|
||||||
|
const saved = JSON.parse(localStorage.getItem(STATE_KEY) || "{}");
|
||||||
|
if (saved.activeTab) selectTab(saved.activeTab);
|
||||||
|
|
||||||
|
// Save on interaction
|
||||||
|
function saveState(state) {
|
||||||
|
localStorage.setItem(STATE_KEY, JSON.stringify(state));
|
||||||
|
}
|
||||||
|
|
||||||
|
// For durable state, persist server-side
|
||||||
|
async function saveServerState(state) {
|
||||||
|
await app.callServerTool('save-dashboard-state', { state: JSON.stringify(state) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fullscreen Toggling
|
||||||
|
|
||||||
|
Switch between inline and fullscreen display modes and react to mode changes:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
document.getElementById("expand-btn").addEventListener("click", () => {
|
||||||
|
app.requestDisplayMode("fullscreen");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.onHostContextChanged((ctx) => {
|
||||||
|
document.body.classList.toggle(
|
||||||
|
"fullscreen",
|
||||||
|
ctx.displayMode === "fullscreen",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Model Context Updates
|
||||||
|
|
||||||
|
Keep the model informed about what the user is viewing so it can provide relevant assistance:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
async function notifyContext(view, detail) {
|
||||||
|
await app.updateModelContext({
|
||||||
|
currentView: view,
|
||||||
|
detail: detail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify on tab change
|
||||||
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
|
tab.addEventListener("click", () => {
|
||||||
|
notifyContext(tab.dataset.view, { filters: getActiveFilters() });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// For large payloads, follow up with sendMessage
|
||||||
|
await app.updateModelContext({ currentView: "report", rows: 5000 });
|
||||||
|
await app.sendMessage("The user is viewing a report with 5000 rows.");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pause Offscreen Views
|
||||||
|
|
||||||
|
Conserve resources by pausing animations and polling when the view is not visible:
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (!pollInterval) {
|
||||||
|
pollInterval = setInterval(fetchData, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const observer = new IntersectionObserver(([entry]) => {
|
||||||
|
entry.isIntersecting ? startPolling() : stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.documentElement);
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Error Handling
|
||||||
|
|
||||||
|
Return `Response::error()` from tools and use `updateModelContext()` to signal degraded state:
|
||||||
|
|
||||||
|
```php
|
||||||
|
class ProcessData extends Tool
|
||||||
|
{
|
||||||
|
public function handle(Request $request): Response
|
||||||
|
{
|
||||||
|
$request->validate(['input' => 'required|string']);
|
||||||
|
|
||||||
|
if (strlen($request->get('input')) > 10_000) {
|
||||||
|
return Response::error('Input exceeds 10KB limit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return Response::json(process($request->get('input')));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
createMcpApp(async (app) => {
|
||||||
|
const result = await app.callServerTool('process-data', { input: value });
|
||||||
|
|
||||||
|
if (result.isError) {
|
||||||
|
document.getElementById("error").textContent =
|
||||||
|
result.content[0]?.text ?? "Unknown error";
|
||||||
|
await app.updateModelContext({
|
||||||
|
state: "error",
|
||||||
|
message: result.content[0]?.text,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderOutput(JSON.parse(result.content[0]?.text ?? "{}"));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
@ -11,6 +11,13 @@ WEBHOOK_URL=
|
||||||
# Self-hosted mode (skips payment requirements)
|
# Self-hosted mode (skips payment requirements)
|
||||||
SELF_HOSTED=true
|
SELF_HOSTED=true
|
||||||
|
|
||||||
|
# Passport OAuth keys (API tokens / MCP). Prefer env vars over key files so
|
||||||
|
# every node behind a load balancer shares the same key pair. Use literal \n
|
||||||
|
# for newlines in the PEM. When unset, Passport falls back to storage/oauth-*.key
|
||||||
|
# (generate with: php artisan passport:keys).
|
||||||
|
# PASSPORT_PRIVATE_KEY="-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----"
|
||||||
|
# PASSPORT_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
||||||
|
|
||||||
TELESCOPE_ENABLED=false
|
TELESCOPE_ENABLED=false
|
||||||
|
|
||||||
APP_LOCALE=en
|
APP_LOCALE=en
|
||||||
|
|
|
||||||
38
AGENTS.md
38
AGENTS.md
|
|
@ -7,36 +7,11 @@ # Laravel Boost Guidelines
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||||
|
|
||||||
- php - 8.5
|
Before relying on a package's API, confirm its installed version:
|
||||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v3
|
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||||
- laravel/ai (AI) - v0
|
- JS packages: check `package.json` for the installed versions.
|
||||||
- laravel/boost (BOOST) - v2
|
|
||||||
- laravel/cashier (CASHIER) - v16
|
|
||||||
- laravel/framework (LARAVEL) - v13
|
|
||||||
- laravel/horizon (HORIZON) - v5
|
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/nightwatch (NIGHTWATCH) - v1
|
|
||||||
- laravel/passport (PASSPORT) - v13
|
|
||||||
- laravel/prompts (PROMPTS) - v0
|
|
||||||
- laravel/reverb (REVERB) - v1
|
|
||||||
- laravel/socialite (SOCIALITE) - v5
|
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
|
||||||
- laravel/pail (PAIL) - v1
|
|
||||||
- laravel/pint (PINT) - v1
|
|
||||||
- laravel/sail (SAIL) - v1
|
|
||||||
- laravel/telescope (TELESCOPE) - v5
|
|
||||||
- pestphp/pest (PEST) - v5
|
|
||||||
- phpunit/phpunit (PHPUNIT) - v13
|
|
||||||
- @inertiajs/vue3 (INERTIA_VUE) - v3
|
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
|
||||||
- vue (VUE) - v3
|
|
||||||
- @laravel/echo-vue (ECHO_VUE) - v2
|
|
||||||
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
|
|
||||||
- eslint (ESLINT) - v9
|
|
||||||
- laravel-echo (ECHO) - v2
|
|
||||||
- prettier (PRETTIER) - v3
|
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
|
|
@ -95,6 +70,11 @@ ### Search Syntax
|
||||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||||
|
|
||||||
|
## Project Rules
|
||||||
|
|
||||||
|
- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule.
|
||||||
|
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
|
|
|
||||||
38
CLAUDE.md
38
CLAUDE.md
|
|
@ -7,36 +7,11 @@ # Laravel Boost Guidelines
|
||||||
|
|
||||||
## Foundational Context
|
## Foundational Context
|
||||||
|
|
||||||
This application is a Laravel application and its main Laravel ecosystems package & versions are below. You are an expert with them all. Ensure you abide by these specific packages & versions.
|
This application is a Laravel application running on PHP 8.5. You are an expert with the Laravel ecosystem. Always use the APIs that match the installed major version of each package — do not assume a version.
|
||||||
|
|
||||||
- php - 8.5
|
Before relying on a package's API, confirm its installed version:
|
||||||
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v3
|
- PHP packages: run `composer show --direct` to list direct dependencies with versions, or `composer show <vendor/package>` for a single package.
|
||||||
- laravel/ai (AI) - v0
|
- JS packages: check `package.json` for the installed versions.
|
||||||
- laravel/boost (BOOST) - v2
|
|
||||||
- laravel/cashier (CASHIER) - v16
|
|
||||||
- laravel/framework (LARAVEL) - v13
|
|
||||||
- laravel/horizon (HORIZON) - v5
|
|
||||||
- laravel/mcp (MCP) - v0
|
|
||||||
- laravel/nightwatch (NIGHTWATCH) - v1
|
|
||||||
- laravel/passport (PASSPORT) - v13
|
|
||||||
- laravel/prompts (PROMPTS) - v0
|
|
||||||
- laravel/reverb (REVERB) - v1
|
|
||||||
- laravel/socialite (SOCIALITE) - v5
|
|
||||||
- laravel/wayfinder (WAYFINDER) - v0
|
|
||||||
- laravel/pail (PAIL) - v1
|
|
||||||
- laravel/pint (PINT) - v1
|
|
||||||
- laravel/sail (SAIL) - v1
|
|
||||||
- laravel/telescope (TELESCOPE) - v5
|
|
||||||
- pestphp/pest (PEST) - v5
|
|
||||||
- phpunit/phpunit (PHPUNIT) - v13
|
|
||||||
- @inertiajs/vue3 (INERTIA_VUE) - v3
|
|
||||||
- tailwindcss (TAILWINDCSS) - v4
|
|
||||||
- vue (VUE) - v3
|
|
||||||
- @laravel/echo-vue (ECHO_VUE) - v2
|
|
||||||
- @laravel/vite-plugin-wayfinder (WAYFINDER_VITE) - v0
|
|
||||||
- eslint (ESLINT) - v9
|
|
||||||
- laravel-echo (ECHO) - v2
|
|
||||||
- prettier (PRETTIER) - v3
|
|
||||||
|
|
||||||
## Skills Activation
|
## Skills Activation
|
||||||
|
|
||||||
|
|
@ -95,6 +70,11 @@ ### Search Syntax
|
||||||
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
3. Combine words and phrases for mixed queries: `middleware "rate limit"`.
|
||||||
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
4. Use multiple queries for OR logic: `queries=["authentication", "middleware"]`.
|
||||||
|
|
||||||
|
## Project Rules
|
||||||
|
|
||||||
|
- This project keeps committed, area-grouped rules in `.ai/rules` (settled decisions, non-obvious traps, standing constraints). Framework and package guidelines that only apply to specific paths (testing, frontend, components) also live there, under `.ai/rules/boost` — this is not just recorded decisions, it is load-bearing guidance you have not seen inline. Before you enter plan mode or create/edit any file, you MUST first: open @.ai/rules/index.md (it maps file globs to rule files), read every rule file whose globs cover the path(s) in scope, and run `grep -rin 'keyword' .ai/rules` to catch what a path match alone misses. Do not write code until you have read and are following every matching rule.
|
||||||
|
- Record durable rules with `record-rule` so the next agent or teammate inherits them instead of working them out again. Pass a `glob` (e.g. `app/Http/Controllers/**`), a short `title`, and a few-line `note`. Always use `record-rule`, never your native memory or notes tool — native memory is personal and session-scoped; only `.ai/rules` is shared with the team and persists in the repo.
|
||||||
|
|
||||||
## Artisan
|
## Artisan
|
||||||
|
|
||||||
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
- Run Artisan commands directly via the command line (e.g., `php artisan route:list`). Use `php artisan list` to discover available commands and `php artisan [command] --help` to check parameters.
|
||||||
|
|
|
||||||
44
app/Actions/AccessToken/ListConnectedMcpClients.php
Normal file
44
app/Actions/AccessToken/ListConnectedMcpClients.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\AccessToken;
|
||||||
|
|
||||||
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
|
||||||
|
class ListConnectedMcpClients
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The viewer's own MCP OAuth connections, keyed by OAuth client.
|
||||||
|
* Matches API keys: each person only sees what they connected.
|
||||||
|
*
|
||||||
|
* @return list<array{client_id: string, name: string, can_disconnect: bool, last_used_at: mixed}>
|
||||||
|
*/
|
||||||
|
public static function forUser(User $user): array
|
||||||
|
{
|
||||||
|
$tokens = AccessToken::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->connectedMcpOAuth()
|
||||||
|
->with(['client', 'user.currentWorkspace', 'workspace', 'refreshToken'])
|
||||||
|
->get();
|
||||||
|
|
||||||
|
return $tokens
|
||||||
|
->filter(fn (AccessToken $token): bool => $token->isListedMcpConnection($user))
|
||||||
|
->groupBy('client_id')
|
||||||
|
->map(function (Collection $group): array {
|
||||||
|
/** @var AccessToken $token */
|
||||||
|
$token = $group->first();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'client_id' => $token->client_id,
|
||||||
|
'name' => $token->client->name,
|
||||||
|
'can_disconnect' => true,
|
||||||
|
'last_used_at' => $group->max('last_used_at'),
|
||||||
|
];
|
||||||
|
})
|
||||||
|
->values()
|
||||||
|
->all();
|
||||||
|
}
|
||||||
|
}
|
||||||
94
app/Actions/AccessToken/RevokeMcpOAuthGrants.php
Normal file
94
app/Actions/AccessToken/RevokeMcpOAuthGrants.php
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\AccessToken;
|
||||||
|
|
||||||
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Illuminate\Support\Collection;
|
||||||
|
use Illuminate\Support\Facades\DB;
|
||||||
|
|
||||||
|
class RevokeMcpOAuthGrants
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Revoke MCP OAuth grants only when the user can no longer view any
|
||||||
|
* workspace (full removal). Demotion to Viewer keeps the grant — write
|
||||||
|
* tools enforce createPost via policies, matching the web app.
|
||||||
|
*
|
||||||
|
* @return bool True when at least one grant was revoked.
|
||||||
|
*/
|
||||||
|
public static function forUserIfLacksWorkspaceAccess(User $user): bool
|
||||||
|
{
|
||||||
|
if (self::canViewSomewhere($user)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::forUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke every active MCP OAuth access token (and its refresh tokens) for the user.
|
||||||
|
*
|
||||||
|
* @return bool True when at least one grant was revoked.
|
||||||
|
*/
|
||||||
|
public static function forUser(User $user): bool
|
||||||
|
{
|
||||||
|
return self::revoke(
|
||||||
|
AccessToken::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->mcpOAuth()
|
||||||
|
->where('revoked', false)
|
||||||
|
->get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke active MCP OAuth grants for one OAuth client owned by the user.
|
||||||
|
*
|
||||||
|
* @return bool True when at least one grant was revoked.
|
||||||
|
*/
|
||||||
|
public static function forUserClient(User $user, string $clientId): bool
|
||||||
|
{
|
||||||
|
return self::revoke(
|
||||||
|
AccessToken::query()
|
||||||
|
->where('user_id', $user->id)
|
||||||
|
->where('client_id', $clientId)
|
||||||
|
->mcpOAuth()
|
||||||
|
->where('revoked', false)
|
||||||
|
->get(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function canViewSomewhere(User $user): bool
|
||||||
|
{
|
||||||
|
return $user->workspaces()
|
||||||
|
->get()
|
||||||
|
->contains(fn (Workspace $workspace): bool => $user->can('view', $workspace));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Collection<int, AccessToken> $tokens
|
||||||
|
*/
|
||||||
|
private static function revoke(Collection $tokens): bool
|
||||||
|
{
|
||||||
|
if ($tokens->isEmpty()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
DB::transaction(function () use ($tokens): void {
|
||||||
|
$tokenIds = $tokens->pluck('id');
|
||||||
|
|
||||||
|
DB::table('oauth_refresh_tokens')
|
||||||
|
->whereIn('access_token_id', $tokenIds)
|
||||||
|
->update(['revoked' => true]);
|
||||||
|
|
||||||
|
$tokens->each(function (AccessToken $token): void {
|
||||||
|
$token->forceFill(['revoked' => true])->saveQuietly();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
46
app/Actions/AccessToken/RevokeWorkspaceApiKeys.php
Normal file
46
app/Actions/AccessToken/RevokeWorkspaceApiKeys.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\AccessToken;
|
||||||
|
|
||||||
|
use App\Enums\UserWorkspace\Role as WorkspaceRole;
|
||||||
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
class RevokeWorkspaceApiKeys
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Revoke personal-access API keys for a user on one workspace.
|
||||||
|
* Used when the member is removed or demoted below Admin (manageTeam).
|
||||||
|
*
|
||||||
|
* @return int Number of tokens revoked.
|
||||||
|
*/
|
||||||
|
public static function forUserOnWorkspace(string $userId, Workspace $workspace): int
|
||||||
|
{
|
||||||
|
return AccessToken::query()
|
||||||
|
->where('user_id', $userId)
|
||||||
|
->where('workspace_id', $workspace->id)
|
||||||
|
->where('revoked', false)
|
||||||
|
->personalAccessApiKey()
|
||||||
|
->update(['revoked' => true]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admins (and account owners acting as admin) may keep workspace API keys.
|
||||||
|
* Any other role loses them.
|
||||||
|
*
|
||||||
|
* @return int Number of tokens revoked.
|
||||||
|
*/
|
||||||
|
public static function forUserUnlessAdmin(
|
||||||
|
string $userId,
|
||||||
|
Workspace $workspace,
|
||||||
|
WorkspaceRole $role,
|
||||||
|
): int {
|
||||||
|
if ($role === WorkspaceRole::Admin) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::forUserOnWorkspace($userId, $workspace);
|
||||||
|
}
|
||||||
|
}
|
||||||
44
app/Actions/ApiKey/CreateApiKey.php
Normal file
44
app/Actions/ApiKey/CreateApiKey.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Actions\ApiKey;
|
||||||
|
|
||||||
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Workspace;
|
||||||
|
|
||||||
|
class CreateApiKey
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function expiresAtRules(): array
|
||||||
|
{
|
||||||
|
return ['nullable', 'date', 'after_or_equal:today'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array{name: string, expires_at?: string|null} $data
|
||||||
|
* @return array{token: AccessToken, plain_token: string}
|
||||||
|
*/
|
||||||
|
public static function execute(User $user, Workspace $workspace, array $data): array
|
||||||
|
{
|
||||||
|
$result = $user->createToken(data_get($data, 'name'));
|
||||||
|
$token = AccessToken::query()->findOrFail($result->token->id);
|
||||||
|
$expiresAt = data_get($data, 'expires_at');
|
||||||
|
|
||||||
|
$token->forceFill([
|
||||||
|
'workspace_id' => $workspace->id,
|
||||||
|
// Empty = never expire (overrides Passport's JWT default lifetime in the DB).
|
||||||
|
'expires_at' => filled($expiresAt)
|
||||||
|
? now()->parse($expiresAt)->endOfDay()
|
||||||
|
: null,
|
||||||
|
])->saveQuietly();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'token' => $token->refresh(),
|
||||||
|
'plain_token' => $result->accessToken,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -18,7 +18,7 @@ public static function execute(Workspace $workspace, array $data): Invite
|
||||||
'account_id' => $workspace->account_id,
|
'account_id' => $workspace->account_id,
|
||||||
'invited_by' => auth()->id(),
|
'invited_by' => auth()->id(),
|
||||||
'email' => data_get($data, 'email'),
|
'email' => data_get($data, 'email'),
|
||||||
'role' => WorkspaceRole::from((string) data_get($data, 'role')),
|
'role' => WorkspaceRole::from(data_get($data, 'role')),
|
||||||
'workspaces' => [$workspace->id],
|
'workspaces' => [$workspace->id],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@
|
||||||
|
|
||||||
namespace App\Actions\Invite;
|
namespace App\Actions\Invite;
|
||||||
|
|
||||||
|
use App\Actions\AccessToken\RevokeMcpOAuthGrants;
|
||||||
|
use App\Actions\AccessToken\RevokeWorkspaceApiKeys;
|
||||||
use App\Actions\User\ReassignCurrentWorkspace;
|
use App\Actions\User\ReassignCurrentWorkspace;
|
||||||
use App\Actions\User\SettleStrandedMember;
|
use App\Actions\User\SettleStrandedMember;
|
||||||
use App\Actions\User\StrandedSettlement;
|
use App\Actions\User\StrandedSettlement;
|
||||||
|
|
@ -30,6 +32,7 @@ public static function execute(Workspace $workspace, string $userId): void
|
||||||
$user = User::query()->find($userId);
|
$user = User::query()->find($userId);
|
||||||
|
|
||||||
$workspace->members()->detach($userId);
|
$workspace->members()->detach($userId);
|
||||||
|
RevokeWorkspaceApiKeys::forUserOnWorkspace($userId, $workspace);
|
||||||
|
|
||||||
if (! $user) {
|
if (! $user) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -50,6 +53,14 @@ public static function execute(Workspace $workspace, string $userId): void
|
||||||
) {
|
) {
|
||||||
$settlement = SettleStrandedMember::execute($user, $account);
|
$settlement = SettleStrandedMember::execute($user, $account);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If the member still exists but can no longer view any workspace,
|
||||||
|
// drop their MCP OAuth grants (refresh tokens included).
|
||||||
|
$remaining = User::query()->find($userId);
|
||||||
|
|
||||||
|
if ($remaining instanceof User) {
|
||||||
|
RevokeMcpOAuthGrants::forUserIfLacksWorkspaceAccess($remaining);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$settlement->flush();
|
$settlement->flush();
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\Api;
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Actions\ApiKey\CreateApiKey;
|
||||||
use App\Http\Requests\Api\ApiKey\StoreApiKeyRequest;
|
use App\Http\Requests\Api\ApiKey\StoreApiKeyRequest;
|
||||||
use App\Http\Resources\Api\ApiKeyResource;
|
use App\Http\Resources\Api\ApiKeyResource;
|
||||||
use App\Models\AccessToken;
|
use App\Models\AccessToken;
|
||||||
|
|
@ -16,6 +17,8 @@ class ApiKeyController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): AnonymousResourceCollection
|
public function index(Request $request): AnonymousResourceCollection
|
||||||
{
|
{
|
||||||
|
$this->authorize('manageTeam', $request->user()->currentWorkspace);
|
||||||
|
|
||||||
$tokens = AccessToken::where('user_id', $request->user()->id)
|
$tokens = AccessToken::where('user_id', $request->user()->id)
|
||||||
->where('workspace_id', $request->user()->currentWorkspace->id)
|
->where('workspace_id', $request->user()->currentWorkspace->id)
|
||||||
->where('revoked', false)
|
->where('revoked', false)
|
||||||
|
|
@ -28,24 +31,24 @@ public function index(Request $request): AnonymousResourceCollection
|
||||||
public function store(StoreApiKeyRequest $request): JsonResponse
|
public function store(StoreApiKeyRequest $request): JsonResponse
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()->currentWorkspace;
|
||||||
$validated = $request->validated();
|
$this->authorize('manageTeam', $workspace);
|
||||||
|
|
||||||
$result = $request->user()->createToken($validated['name']);
|
$created = CreateApiKey::execute(
|
||||||
|
$request->user(),
|
||||||
$token = AccessToken::find($result->token->id);
|
$workspace,
|
||||||
$token->forceFill([
|
$request->validated(),
|
||||||
'workspace_id' => $workspace->id,
|
);
|
||||||
'expires_at' => $validated['expires_at'] ?? null,
|
|
||||||
])->saveQuietly();
|
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'token' => new ApiKeyResource($token->refresh()),
|
'token' => new ApiKeyResource($created['token']),
|
||||||
'plain_token' => $result->accessToken,
|
'plain_token' => $created['plain_token'],
|
||||||
], Response::HTTP_CREATED);
|
], Response::HTTP_CREATED);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, string $tokenId): JsonResponse
|
public function destroy(Request $request, string $tokenId): JsonResponse
|
||||||
{
|
{
|
||||||
|
$this->authorize('manageTeam', $request->user()->currentWorkspace);
|
||||||
|
|
||||||
$token = AccessToken::where('id', $tokenId)
|
$token = AccessToken::where('id', $tokenId)
|
||||||
->where('user_id', $request->user()->id)
|
->where('user_id', $request->user()->id)
|
||||||
->where('workspace_id', $request->user()->currentWorkspace->id)
|
->where('workspace_id', $request->user()->currentWorkspace->id)
|
||||||
|
|
|
||||||
|
|
@ -4,15 +4,18 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\App;
|
namespace App\Http\Controllers\App;
|
||||||
|
|
||||||
|
use App\Actions\ApiKey\CreateApiKey;
|
||||||
|
use App\Http\Requests\App\ApiKey\StoreApiKeyRequest;
|
||||||
use App\Models\AccessToken;
|
use App\Models\AccessToken;
|
||||||
use Illuminate\Http\RedirectResponse;
|
use Illuminate\Http\RedirectResponse;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class ApiKeyController extends Controller
|
class ApiKeyController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): Response|RedirectResponse
|
public function index(Request $request): InertiaResponse|RedirectResponse
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()->currentWorkspace;
|
||||||
|
|
||||||
|
|
@ -41,7 +44,7 @@ public function index(Request $request): Response|RedirectResponse
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function store(Request $request): RedirectResponse
|
public function store(StoreApiKeyRequest $request): RedirectResponse
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()->currentWorkspace;
|
||||||
|
|
||||||
|
|
@ -51,21 +54,15 @@ public function store(Request $request): RedirectResponse
|
||||||
|
|
||||||
$this->authorize('manageTeam', $workspace);
|
$this->authorize('manageTeam', $workspace);
|
||||||
|
|
||||||
$validated = $request->validate([
|
$created = CreateApiKey::execute(
|
||||||
'name' => ['required', 'string', 'max:255'],
|
$request->user(),
|
||||||
'expires_at' => ['nullable', 'date', 'after:today'],
|
$workspace,
|
||||||
]);
|
$request->validated(),
|
||||||
|
);
|
||||||
$result = $request->user()->createToken($validated['name']);
|
|
||||||
$accessToken = AccessToken::find($result->token->id);
|
|
||||||
$accessToken->forceFill([
|
|
||||||
'workspace_id' => $workspace->id,
|
|
||||||
'expires_at' => $validated['expires_at'] ?? null,
|
|
||||||
])->saveQuietly();
|
|
||||||
|
|
||||||
return back()
|
return back()
|
||||||
->with('flash.success', __('settings.api_keys.flash.created'))
|
->with('flash.success', __('settings.api_keys.flash.created'))
|
||||||
->with('flash.plainToken', $result->accessToken);
|
->with('flash.plainToken', $created['plain_token']);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function destroy(Request $request, string $tokenId): RedirectResponse
|
public function destroy(Request $request, string $tokenId): RedirectResponse
|
||||||
|
|
@ -84,7 +81,7 @@ public function destroy(Request $request, string $tokenId): RedirectResponse
|
||||||
->first();
|
->first();
|
||||||
|
|
||||||
if (! $token) {
|
if (! $token) {
|
||||||
abort(404);
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
$token->forceFill(['revoked' => true])->saveQuietly();
|
$token->forceFill(['revoked' => true])->saveQuietly();
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ public function storeChunked(StoreChunkedAssetRequest $request, ChunkedAssetRece
|
||||||
return $receiver->receive(
|
return $receiver->receive(
|
||||||
$workspace,
|
$workspace,
|
||||||
$request->user(),
|
$request->user(),
|
||||||
(string) $request->validated('file_name'),
|
$request->validated('file_name'),
|
||||||
$request->getContent(),
|
$request->getContent(),
|
||||||
(int) $request->validated('range_start'),
|
(int) $request->validated('range_start'),
|
||||||
(int) $request->validated('range_end'),
|
(int) $request->validated('range_end'),
|
||||||
|
|
@ -99,7 +99,7 @@ public function storeFromUrl(StoreAssetFromUrlRequest $request, UnsplashService
|
||||||
$unsplash->trackDownload($downloadLocation);
|
$unsplash->trackDownload($downloadLocation);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = (string) data_get($validated, 'url');
|
$url = data_get($validated, 'url');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
|
$response = $safeHttp->guardedRequest($url)->timeout(30)->get($url);
|
||||||
|
|
|
||||||
|
|
@ -231,7 +231,7 @@ public function inspectFeed(
|
||||||
$this->authorize('update', $automation);
|
$this->authorize('update', $automation);
|
||||||
|
|
||||||
$feedUrl = $resolver->resolve(
|
$feedUrl = $resolver->resolve(
|
||||||
(string) $request->validated('feed_url'),
|
$request->validated('feed_url'),
|
||||||
['variables' => $automation->resolvedVariables()],
|
['variables' => $automation->resolvedVariables()],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,7 @@ public function mentions(IndexDiscordMentionRequest $request, SocialAccount $acc
|
||||||
return response()->json([
|
return response()->json([
|
||||||
'mentions' => $this->discord->mentions(
|
'mentions' => $this->discord->mentions(
|
||||||
(string) $account->platform_user_id,
|
(string) $account->platform_user_id,
|
||||||
(string) $request->validated('q', ''),
|
$request->validated('q', ''),
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
41
app/Http/Controllers/App/McpSettingsController.php
Normal file
41
app/Http/Controllers/App/McpSettingsController.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\App;
|
||||||
|
|
||||||
|
use App\Actions\AccessToken\ListConnectedMcpClients;
|
||||||
|
use App\Actions\AccessToken\RevokeMcpOAuthGrants;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class McpSettingsController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
$workspace = $user->currentWorkspace;
|
||||||
|
|
||||||
|
$this->authorize('view', $workspace);
|
||||||
|
|
||||||
|
return Inertia::render('settings/workspace/Mcp', [
|
||||||
|
'mcpUrl' => route('mcp.trypost'),
|
||||||
|
'connectedClients' => ListConnectedMcpClients::forUser($user),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function disconnect(Request $request, string $client): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
$this->authorize('view', $user->currentWorkspace);
|
||||||
|
|
||||||
|
if (! RevokeMcpOAuthGrants::forUserClient($user, $client)) {
|
||||||
|
return back();
|
||||||
|
}
|
||||||
|
|
||||||
|
return back()->with('flash.success', __('mcp.disconnected'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -55,7 +55,7 @@ public function store(StoreOnboardingRequest $request, PostHogService $postHog):
|
||||||
return redirect()->route('app.calendar');
|
return redirect()->route('app.calendar');
|
||||||
}
|
}
|
||||||
|
|
||||||
$persona = (string) $request->validated('persona');
|
$persona = $request->validated('persona');
|
||||||
|
|
||||||
$user->update(['persona' => $persona]);
|
$user->update(['persona' => $persona]);
|
||||||
|
|
||||||
|
|
@ -161,7 +161,7 @@ public function storeReferralSource(StoreOnboardingReferralSourceRequest $reques
|
||||||
return redirect()->route('app.onboarding.goals');
|
return redirect()->route('app.onboarding.goals');
|
||||||
}
|
}
|
||||||
|
|
||||||
$referralSource = (string) $request->validated('referral_source');
|
$referralSource = $request->validated('referral_source');
|
||||||
|
|
||||||
$user->update(['referral_source' => $referralSource]);
|
$user->update(['referral_source' => $referralSource]);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
namespace App\Http\Controllers\App;
|
namespace App\Http\Controllers\App;
|
||||||
|
|
||||||
|
use App\Actions\AccessToken\RevokeWorkspaceApiKeys;
|
||||||
use App\Actions\Invite\CreateInvite;
|
use App\Actions\Invite\CreateInvite;
|
||||||
use App\Actions\Invite\DeleteInvite;
|
use App\Actions\Invite\DeleteInvite;
|
||||||
use App\Actions\Invite\RemoveMember;
|
use App\Actions\Invite\RemoveMember;
|
||||||
|
|
@ -15,11 +16,12 @@
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Validation\Rule;
|
use Illuminate\Validation\Rule;
|
||||||
use Inertia\Inertia;
|
use Inertia\Inertia;
|
||||||
use Inertia\Response;
|
use Inertia\Response as InertiaResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class WorkspaceInviteController extends Controller
|
class WorkspaceInviteController extends Controller
|
||||||
{
|
{
|
||||||
public function index(Request $request): Response|RedirectResponse
|
public function index(Request $request): InertiaResponse|RedirectResponse
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()->currentWorkspace;
|
||||||
|
|
||||||
|
|
@ -103,7 +105,7 @@ public function destroy(Request $request, Invite $invite): RedirectResponse
|
||||||
$this->authorize('manageTeam', $workspace);
|
$this->authorize('manageTeam', $workspace);
|
||||||
|
|
||||||
if ($invite->account_id !== $workspace->account_id) {
|
if ($invite->account_id !== $workspace->account_id) {
|
||||||
abort(404);
|
abort(Response::HTTP_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
DeleteInvite::execute($invite);
|
DeleteInvite::execute($invite);
|
||||||
|
|
@ -165,11 +167,14 @@ public function updateRole(Request $request, string $userId): RedirectResponse
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
|
'role' => ['required', Rule::in(array_column(WorkspaceRole::cases(), 'value'))],
|
||||||
]);
|
]);
|
||||||
|
$role = WorkspaceRole::from(data_get($validated, 'role'));
|
||||||
|
|
||||||
$workspace->members()->updateExistingPivot($userId, [
|
$workspace->members()->updateExistingPivot($userId, [
|
||||||
'role' => data_get($validated, 'role'),
|
'role' => $role->value,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
RevokeWorkspaceApiKeys::forUserUnlessAdmin($userId, $workspace, $role);
|
||||||
|
|
||||||
session()->flash('flash.banner', __('settings.members.flash.role_updated'));
|
session()->flash('flash.banner', __('settings.members.flash.role_updated'));
|
||||||
session()->flash('flash.bannerStyle', 'success');
|
session()->flash('flash.bannerStyle', 'success');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,34 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware\Api;
|
namespace App\Http\Middleware\Api;
|
||||||
|
|
||||||
|
use App\Models\AccessToken;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
|
use Laravel\Passport\AccessToken as PassportAccessToken;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
||||||
class LoadWorkspaceFromToken
|
class LoadWorkspaceFromToken
|
||||||
{
|
{
|
||||||
public function handle(Request $request, Closure $next): Response
|
public function handle(Request $request, Closure $next, ?string $context = null): Response
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$user = $request->user();
|
||||||
$token = $user?->token();
|
$authenticatedToken = $user?->token();
|
||||||
|
|
||||||
if (! $token) {
|
if (! $authenticatedToken instanceof PassportAccessToken) {
|
||||||
return response()->json(['message' => 'Token not found.'], Response::HTTP_UNAUTHORIZED);
|
return response()->json(['message' => 'Token not found.'], Response::HTTP_UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$token = AccessToken::query()->find($authenticatedToken->oauth_access_token_id);
|
||||||
|
|
||||||
|
if ($token === null) {
|
||||||
|
return response()->json(['message' => 'Token not found.'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($token->expires_at?->isPast()) {
|
||||||
|
return response()->json(['message' => 'Token expired.'], Response::HTTP_UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
// Personal API tokens (created from settings) bind to a specific
|
// Personal API tokens (created from settings) bind to a specific
|
||||||
// workspace at creation. OAuth tokens (e.g. ChatGPT MCP) don't —
|
// workspace at creation. OAuth tokens (e.g. ChatGPT MCP) don't —
|
||||||
// they follow the user's current workspace.
|
// they follow the user's current workspace.
|
||||||
|
|
@ -31,7 +43,27 @@ public function handle(Request $request, Closure $next): Response
|
||||||
return response()->json(['message' => 'No workspace selected.'], Response::HTTP_UNAUTHORIZED);
|
return response()->json(['message' => 'No workspace selected.'], Response::HTTP_UNAUTHORIZED);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (! config('trypost.self_hosted') && ! $workspace->account?->hasActiveSubscription()) {
|
if (! $user->can('view', $workspace)) {
|
||||||
|
return response()->json(['message' => 'Workspace access denied.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($context === 'mcp') {
|
||||||
|
if (! $token->isActiveMcpGrant() || ! $authenticatedToken->can('mcp:use')) {
|
||||||
|
return response()->json(['message' => 'MCP OAuth authorization required.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (! $token->isPersonalAccessToken()) {
|
||||||
|
return response()->json(['message' => 'Personal access token required.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $user->can('manageTeam', $workspace)) {
|
||||||
|
return response()->json(['message' => 'Insufficient workspace permissions.'], Response::HTTP_FORBIDDEN);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match web access (EnsureAccountReady): Stripe subscription OR generic
|
||||||
|
// no-card trial when REQUIRE_CARD_FOR_TRIAL is disabled.
|
||||||
|
if (! config('trypost.self_hosted') && ! $workspace->account?->hasAppAccess()) {
|
||||||
return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED);
|
return response()->json(['message' => 'Active subscription required.'], Response::HTTP_PAYMENT_REQUIRED);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@
|
||||||
|
|
||||||
namespace App\Http\Middleware\App;
|
namespace App\Http\Middleware\App;
|
||||||
|
|
||||||
use App\Models\Account;
|
|
||||||
use Closure;
|
use Closure;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
|
@ -24,13 +23,8 @@ public function handle(Request $request, Closure $next): Response
|
||||||
|
|
||||||
if (! config('trypost.self_hosted')) {
|
if (! config('trypost.self_hosted')) {
|
||||||
$account = $user->account;
|
$account = $user->account;
|
||||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
|
||||||
$hasAccess = $account && (
|
|
||||||
$account->subscribed(Account::SUBSCRIPTION_NAME)
|
|
||||||
|| (! $requiresCardForTrial && $account->isOnTrial())
|
|
||||||
);
|
|
||||||
|
|
||||||
if (! $hasAccess) {
|
if (! $account?->hasAppAccess()) {
|
||||||
return redirect()->route('app.onboarding');
|
return redirect()->route('app.onboarding');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\Api\ApiKey;
|
namespace App\Http\Requests\Api\ApiKey;
|
||||||
|
|
||||||
|
use App\Actions\ApiKey\CreateApiKey;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class StoreApiKeyRequest extends FormRequest
|
class StoreApiKeyRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
$user = $this->user();
|
||||||
|
$workspace = $user?->currentWorkspace;
|
||||||
|
|
||||||
|
return $workspace !== null
|
||||||
|
&& $user->can('manageTeam', $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +25,7 @@ public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'expires_at' => ['nullable', 'date', 'after:today'],
|
'expires_at' => CreateApiKey::expiresAtRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,18 @@
|
||||||
|
|
||||||
namespace App\Http\Requests\App\ApiKey;
|
namespace App\Http\Requests\App\ApiKey;
|
||||||
|
|
||||||
|
use App\Actions\ApiKey\CreateApiKey;
|
||||||
use Illuminate\Foundation\Http\FormRequest;
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
|
||||||
class StoreApiKeyRequest extends FormRequest
|
class StoreApiKeyRequest extends FormRequest
|
||||||
{
|
{
|
||||||
public function authorize(): bool
|
public function authorize(): bool
|
||||||
{
|
{
|
||||||
return true;
|
$user = $this->user();
|
||||||
|
$workspace = $user?->currentWorkspace;
|
||||||
|
|
||||||
|
return $workspace !== null
|
||||||
|
&& $user->can('manageTeam', $workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -20,7 +25,7 @@ public function rules(): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'expires_at' => ['nullable', 'date', 'after:today'],
|
'expires_at' => CreateApiKey::expiresAtRules(),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
52
app/Mcp/Concerns/AuthorizesMcpTool.php
Normal file
52
app/Mcp/Concerns/AuthorizesMcpTool.php
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Mcp\Concerns;
|
||||||
|
|
||||||
|
use App\Models\Workspace;
|
||||||
|
use Laravel\Mcp\Request;
|
||||||
|
use Laravel\Mcp\Response;
|
||||||
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
|
||||||
|
trait AuthorizesMcpTool
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Mirror web policies inside MCP tools. Returns an error response when denied.
|
||||||
|
* Fails closed when the request has no user or when $arguments is null.
|
||||||
|
*/
|
||||||
|
protected function denyUnlessCan(
|
||||||
|
Request $request,
|
||||||
|
string $ability,
|
||||||
|
mixed $arguments,
|
||||||
|
string $message,
|
||||||
|
): Response|ResponseFactory|null {
|
||||||
|
$user = $request->user();
|
||||||
|
|
||||||
|
if ($user === null || $arguments === null || $user->cannot($ability, $arguments)) {
|
||||||
|
return Response::error($message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Authorize a workspace-level ability against the current workspace.
|
||||||
|
* Resolves the workspace via nullsafe access so missing auth never TypeErrors.
|
||||||
|
*/
|
||||||
|
protected function authorizeCurrentWorkspace(
|
||||||
|
Request $request,
|
||||||
|
string $ability,
|
||||||
|
string $message,
|
||||||
|
): Workspace|Response|ResponseFactory {
|
||||||
|
$workspace = $request->user()?->currentWorkspace;
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, $ability, $workspace, $message)) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
|
assert($workspace instanceof Workspace);
|
||||||
|
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -33,12 +33,14 @@
|
||||||
use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool;
|
use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool;
|
||||||
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
|
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
|
||||||
use Laravel\Mcp\Server;
|
use Laravel\Mcp\Server;
|
||||||
|
use Laravel\Mcp\Server\Attributes\Icon;
|
||||||
use Laravel\Mcp\Server\Attributes\Instructions;
|
use Laravel\Mcp\Server\Attributes\Instructions;
|
||||||
use Laravel\Mcp\Server\Attributes\Name;
|
use Laravel\Mcp\Server\Attributes\Name;
|
||||||
use Laravel\Mcp\Server\Attributes\Version;
|
use Laravel\Mcp\Server\Attributes\Version;
|
||||||
|
|
||||||
#[Name('TryPost')]
|
#[Name('TryPost')]
|
||||||
#[Version('1.0.0')]
|
#[Version('1.0.0')]
|
||||||
|
#[Icon('images/trypost/icon.png', mimeType: 'image/png')]
|
||||||
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, social accounts, workspaces, and API keys.')]
|
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, social accounts, workspaces, and API keys.')]
|
||||||
class TryPostServer extends Server
|
class TryPostServer extends Server
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
|
|
||||||
namespace App\Mcp\Tools\ApiKey;
|
namespace App\Mcp\Tools\ApiKey;
|
||||||
|
|
||||||
|
use App\Actions\ApiKey\CreateApiKey;
|
||||||
use App\Http\Resources\Api\ApiKeyResource;
|
use App\Http\Resources\Api\ApiKeyResource;
|
||||||
use App\Models\AccessToken;
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -16,26 +18,30 @@
|
||||||
#[Description('Create a new Personal Access Token (API key) for the current workspace. The plain token value is returned ONCE — store it immediately, it cannot be retrieved later.')]
|
#[Description('Create a new Personal Access Token (API key) for the current workspace. The plain token value is returned ONCE — store it immediately, it cannot be retrieved later.')]
|
||||||
class CreateApiKeyTool extends Tool
|
class CreateApiKeyTool extends Tool
|
||||||
{
|
{
|
||||||
public function handle(Request $request): ResponseFactory
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'manageTeam',
|
||||||
|
'Not authorized to manage API keys.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'expires_at' => ['nullable', 'date', 'after:now'],
|
'expires_at' => CreateApiKey::expiresAtRules(),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$user = $request->user();
|
$created = CreateApiKey::execute($request->user(), $workspace, $validated);
|
||||||
|
|
||||||
$result = $user->createToken(data_get($validated, 'name'));
|
|
||||||
|
|
||||||
$token = AccessToken::find($result->token->id);
|
|
||||||
$token->forceFill([
|
|
||||||
'workspace_id' => $user->current_workspace_id,
|
|
||||||
'expires_at' => data_get($validated, 'expires_at'),
|
|
||||||
])->saveQuietly();
|
|
||||||
|
|
||||||
return Response::structured(array_merge(
|
return Response::structured(array_merge(
|
||||||
(new ApiKeyResource($token))->resolve(),
|
(new ApiKeyResource($created['token']))->resolve(),
|
||||||
['token' => $result->accessToken],
|
['token' => $created['plain_token']],
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,7 +49,7 @@ public function schema(JsonSchema $schema): array
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
'name' => $schema->string()->required()->description('A human-readable name to identify the key (e.g. "My integration").'),
|
'name' => $schema->string()->required()->description('A human-readable name to identify the key (e.g. "My integration").'),
|
||||||
'expires_at' => $schema->string()->description('Optional ISO 8601 expiration date (e.g. 2026-12-31). Must be in the future.'),
|
'expires_at' => $schema->string()->description('Optional expiration date (YYYY-MM-DD or ISO 8601). Omit for a key that never expires.'),
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,9 @@
|
||||||
|
|
||||||
namespace App\Mcp\Tools\ApiKey;
|
namespace App\Mcp\Tools\ApiKey;
|
||||||
|
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\AccessToken;
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -17,15 +19,27 @@
|
||||||
#[Description('Revoke (delete) a Personal Access Token by ID. The current OAuth session token cannot be revoked through this tool. Existing integrations using the token will stop working.')]
|
#[Description('Revoke (delete) a Personal Access Token by ID. The current OAuth session token cannot be revoked through this tool. Existing integrations using the token will stop working.')]
|
||||||
class DeleteApiKeyTool extends Tool
|
class DeleteApiKeyTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'manageTeam',
|
||||||
|
'Not authorized to manage API keys.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate(['api_key_id' => ['required', 'string']]);
|
$validated = $request->validate(['api_key_id' => ['required', 'string']]);
|
||||||
|
|
||||||
// workspace_id filter excludes OAuth-flow tokens (which have null
|
// workspace_id filter excludes OAuth-flow tokens (which have null
|
||||||
// workspace_id), so the caller can't accidentally revoke their own
|
// workspace_id), so the caller can't accidentally revoke their own
|
||||||
// ChatGPT/MCP session token through this tool.
|
// ChatGPT/MCP session token through this tool.
|
||||||
$token = AccessToken::where('user_id', $request->user()->id)
|
$token = AccessToken::where('user_id', $request->user()->id)
|
||||||
->where('workspace_id', $request->user()->current_workspace_id)
|
->where('workspace_id', $workspace->id)
|
||||||
->where('revoked', false)
|
->where('revoked', false)
|
||||||
->find(data_get($validated, 'api_key_id'));
|
->find(data_get($validated, 'api_key_id'));
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
namespace App\Mcp\Tools\ApiKey;
|
namespace App\Mcp\Tools\ApiKey;
|
||||||
|
|
||||||
use App\Http\Resources\Api\ApiKeyResource;
|
use App\Http\Resources\Api\ApiKeyResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\AccessToken;
|
use App\Models\AccessToken;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
use Laravel\Mcp\ResponseFactory;
|
use Laravel\Mcp\ResponseFactory;
|
||||||
|
|
@ -17,13 +19,25 @@
|
||||||
#[Description('List all Personal Access Tokens (API keys) for the current workspace. Returns metadata only — the secret token value is shown only once at creation. OAuth tokens (e.g. ChatGPT MCP sessions) are excluded.')]
|
#[Description('List all Personal Access Tokens (API keys) for the current workspace. Returns metadata only — the secret token value is shown only once at creation. OAuth tokens (e.g. ChatGPT MCP sessions) are excluded.')]
|
||||||
class ListApiKeysTool extends Tool
|
class ListApiKeysTool extends Tool
|
||||||
{
|
{
|
||||||
public function handle(Request $request): ResponseFactory
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'manageTeam',
|
||||||
|
'Not authorized to manage API keys.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
// Filtering by workspace_id excludes OAuth-flow tokens (whose
|
// Filtering by workspace_id excludes OAuth-flow tokens (whose
|
||||||
// workspace_id is null and resolved at request time via
|
// workspace_id is null and resolved at request time via
|
||||||
// LoadWorkspaceFromToken middleware).
|
// LoadWorkspaceFromToken middleware).
|
||||||
$tokens = AccessToken::where('user_id', $request->user()->id)
|
$tokens = AccessToken::where('user_id', $request->user()->id)
|
||||||
->where('workspace_id', $request->user()->current_workspace_id)
|
->where('workspace_id', $workspace->id)
|
||||||
->where('revoked', false)
|
->where('revoked', false)
|
||||||
->latest()
|
->latest()
|
||||||
->get();
|
->get();
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
use App\Actions\Label\CreateLabel;
|
use App\Actions\Label\CreateLabel;
|
||||||
use App\Http\Resources\Api\LabelResource;
|
use App\Http\Resources\Api\LabelResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -16,14 +18,26 @@
|
||||||
#[Description('Create a new label with a name and hex color.')]
|
#[Description('Create a new label with a name and hex color.')]
|
||||||
class CreateLabelTool extends Tool
|
class CreateLabelTool extends Tool
|
||||||
{
|
{
|
||||||
public function handle(Request $request): ResponseFactory
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage labels.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$label = CreateLabel::execute($request->user()->currentWorkspace, $validated);
|
$label = CreateLabel::execute($workspace, $validated);
|
||||||
|
|
||||||
return Response::structured((new LabelResource($label))->resolve());
|
return Response::structured((new LabelResource($label))->resolve());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
namespace App\Mcp\Tools\Label;
|
namespace App\Mcp\Tools\Label;
|
||||||
|
|
||||||
use App\Actions\Label\DeleteLabel;
|
use App\Actions\Label\DeleteLabel;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceLabel;
|
use App\Models\WorkspaceLabel;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
|
|
@ -18,11 +20,23 @@
|
||||||
#[Description('Delete a label permanently. The label is detached from all posts that referenced it. This cannot be undone.')]
|
#[Description('Delete a label permanently. The label is detached from all posts that referenced it. This cannot be undone.')]
|
||||||
class DeleteLabelTool extends Tool
|
class DeleteLabelTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage labels.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate(['label_id' => ['required', 'string']]);
|
$validated = $request->validate(['label_id' => ['required', 'string']]);
|
||||||
|
|
||||||
$label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id)
|
$label = WorkspaceLabel::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'label_id'));
|
->find(data_get($validated, 'label_id'));
|
||||||
|
|
||||||
if (! $label) {
|
if (! $label) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
use App\Actions\Label\UpdateLabel;
|
use App\Actions\Label\UpdateLabel;
|
||||||
use App\Http\Resources\Api\LabelResource;
|
use App\Http\Resources\Api\LabelResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceLabel;
|
use App\Models\WorkspaceLabel;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
|
|
@ -17,15 +19,27 @@
|
||||||
#[Description('Update a label name or color.')]
|
#[Description('Update a label name or color.')]
|
||||||
class UpdateLabelTool extends Tool
|
class UpdateLabelTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage labels.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'label_id' => ['required', 'string'],
|
'label_id' => ['required', 'string'],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id)
|
$label = WorkspaceLabel::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'label_id'));
|
->find(data_get($validated, 'label_id'));
|
||||||
|
|
||||||
if (! $label) {
|
if (! $label) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
namespace App\Mcp\Tools\Post;
|
namespace App\Mcp\Tools\Post;
|
||||||
|
|
||||||
use App\Http\Resources\Api\PostResource;
|
use App\Http\Resources\Api\PostResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\Media;
|
use App\Models\Media;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Models\Workspace;
|
use App\Models\Workspace;
|
||||||
|
|
@ -19,6 +20,8 @@
|
||||||
#[Description('Attach a Media uploaded via RequestMediaUploadTool to a post. The upload_token is the value returned by RequestMediaUploadTool; the Media is resolved by that token within the current workspace, then appended to the post.')]
|
#[Description('Attach a Media uploaded via RequestMediaUploadTool to a post. The upload_token is the value returned by RequestMediaUploadTool; the Media is resolved by that token within the current workspace, then appended to the post.')]
|
||||||
class AttachMediaFromUploadTool extends Tool
|
class AttachMediaFromUploadTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -27,15 +30,20 @@ public function handle(Request $request): Response|ResponseFactory
|
||||||
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
'alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$workspaceId = $request->user()->current_workspace_id;
|
$workspaceId = $request->user()?->current_workspace_id;
|
||||||
|
|
||||||
$post = Post::where('workspace_id', $workspaceId)
|
$post = $workspaceId
|
||||||
->find(data_get($validated, 'post_id'));
|
? Post::where('workspace_id', $workspaceId)->find(data_get($validated, 'post_id'))
|
||||||
|
: null;
|
||||||
|
|
||||||
if (! $post) {
|
if (! $post) {
|
||||||
return Response::error('Post not found.');
|
return Response::error('Post not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, 'update', $post, 'Not authorized to update this post.')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
$media = Media::query()
|
$media = Media::query()
|
||||||
->where('upload_token', data_get($validated, 'upload_token'))
|
->where('upload_token', data_get($validated, 'upload_token'))
|
||||||
->where('mediable_type', (new Workspace)->getMorphClass())
|
->where('mediable_type', (new Workspace)->getMorphClass())
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
namespace App\Mcp\Tools\Post;
|
namespace App\Mcp\Tools\Post;
|
||||||
|
|
||||||
use App\Http\Resources\Api\PostResource;
|
use App\Http\Resources\Api\PostResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Services\Post\MediaAttacher;
|
use App\Services\Post\MediaAttacher;
|
||||||
use App\Support\PostMediaRules;
|
use App\Support\PostMediaRules;
|
||||||
|
|
@ -18,6 +19,8 @@
|
||||||
#[Description('Download images, videos, or PDF documents from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')]
|
#[Description('Download images, videos, or PDF documents from public URLs and attach them to a post. Each URL is fetched, stored, and registered as a Media record on the workspace. Allowed types are intersected with the platforms enabled on the post (e.g. nothing accepted if no platform supports the media type).')]
|
||||||
class AttachMediaFromUrlTool extends Tool
|
class AttachMediaFromUrlTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
|
|
@ -27,13 +30,17 @@ public function handle(Request $request): Response|ResponseFactory
|
||||||
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
'urls.*.alt' => ['nullable', 'string', 'max:'.PostMediaRules::ALT_TEXT_MAX_LENGTH],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
|
$post = Post::where('workspace_id', $request->user()?->current_workspace_id)
|
||||||
->find(data_get($validated, 'post_id'));
|
->find(data_get($validated, 'post_id'));
|
||||||
|
|
||||||
if (! $post) {
|
if (! $post) {
|
||||||
return Response::error('Post not found.');
|
return Response::error('Post not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, 'update', $post, 'Not authorized to update this post.')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
$result = app(MediaAttacher::class)->attachFromUrls(
|
$result = app(MediaAttacher::class)->attachFromUrls(
|
||||||
$post,
|
$post,
|
||||||
data_get($validated, 'urls', []),
|
data_get($validated, 'urls', []),
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
use App\Enums\Post\CreatedVia;
|
use App\Enums\Post\CreatedVia;
|
||||||
use App\Enums\PostPlatform\ContentType;
|
use App\Enums\PostPlatform\ContentType;
|
||||||
use App\Http\Resources\Api\PostResource;
|
use App\Http\Resources\Api\PostResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Rules\ContentTypeMatchesPlatform;
|
use App\Rules\ContentTypeMatchesPlatform;
|
||||||
use App\Support\PostPlatformMetaRules;
|
use App\Support\PostPlatformMetaRules;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
|
|
@ -21,9 +23,19 @@
|
||||||
#[Description('Create a draft post in the current workspace. Accepts content, scheduled_at, label_ids, and a list of platforms (social accounts to publish on, with their content_type). Use list-content-types-tool to discover valid content_types per platform.')]
|
#[Description('Create a draft post in the current workspace. Accepts content, scheduled_at, label_ids, and a list of platforms (social accounts to publish on, with their content_type). Use list-content-types-tool to discover valid content_types per platform.')]
|
||||||
class CreatePostTool extends Tool
|
class CreatePostTool extends Tool
|
||||||
{
|
{
|
||||||
public function handle(Request $request): ResponseFactory
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to create posts.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate(
|
$validated = $request->validate(
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
namespace App\Mcp\Tools\Post;
|
namespace App\Mcp\Tools\Post;
|
||||||
|
|
||||||
use App\Actions\Post\DeletePost;
|
use App\Actions\Post\DeletePost;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
|
|
@ -18,17 +19,23 @@
|
||||||
#[Description('Delete a post permanently. This cannot be undone.')]
|
#[Description('Delete a post permanently. This cannot be undone.')]
|
||||||
class DeletePostTool extends Tool
|
class DeletePostTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$validated = $request->validate(['post_id' => ['required', 'string']]);
|
$validated = $request->validate(['post_id' => ['required', 'string']]);
|
||||||
|
|
||||||
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
|
$post = Post::where('workspace_id', $request->user()?->current_workspace_id)
|
||||||
->find(data_get($validated, 'post_id'));
|
->find(data_get($validated, 'post_id'));
|
||||||
|
|
||||||
if (! $post) {
|
if (! $post) {
|
||||||
return Response::error('Post not found.');
|
return Response::error('Post not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, 'delete', $post, 'Not authorized to delete this post.')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
DeletePost::execute($post);
|
DeletePost::execute($post);
|
||||||
|
|
||||||
return Response::structured(['deleted' => true]);
|
return Response::structured(['deleted' => true]);
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@
|
||||||
use App\Enums\Post\Action as PostAction;
|
use App\Enums\Post\Action as PostAction;
|
||||||
use App\Enums\Post\Status;
|
use App\Enums\Post\Status;
|
||||||
use App\Http\Resources\Api\PostResource;
|
use App\Http\Resources\Api\PostResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
use App\Rules\ContentTypeCompatibleWithMedia;
|
use App\Rules\ContentTypeCompatibleWithMedia;
|
||||||
use App\Support\PostPlatformMetaRules;
|
use App\Support\PostPlatformMetaRules;
|
||||||
|
|
@ -24,21 +25,28 @@
|
||||||
#[Description('Publish a draft post — either immediately or scheduled for a future time. The post must already have at least one enabled platform. Use update-post-tool first to set content/platforms.')]
|
#[Description('Publish a draft post — either immediately or scheduled for a future time. The post must already have at least one enabled platform. Use update-post-tool first to set content/platforms.')]
|
||||||
class PublishPostTool extends Tool
|
class PublishPostTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'post_id' => ['required', 'uuid'],
|
'post_id' => ['required', 'uuid'],
|
||||||
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$post = Post::where('workspace_id', $workspace->id)->find(data_get($validated, 'post_id'));
|
$workspace = $request->user()?->currentWorkspace;
|
||||||
|
$post = $workspace
|
||||||
|
? Post::where('workspace_id', $workspace->id)->find(data_get($validated, 'post_id'))
|
||||||
|
: null;
|
||||||
|
|
||||||
if (! $post) {
|
if (! $post) {
|
||||||
return Response::error('Post not found.');
|
return Response::error('Post not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, 'update', $post, 'Not authorized to publish this post.')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
if (! $post->postPlatforms()->where('enabled', true)->exists()) {
|
if (! $post->postPlatforms()->where('enabled', true)->exists()) {
|
||||||
return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.');
|
return Response::error('Post has no enabled platforms. Use update-post-tool to enable at least one platform first.');
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
namespace App\Mcp\Tools\Post;
|
namespace App\Mcp\Tools\Post;
|
||||||
|
|
||||||
use App\Enums\Media\Type as MediaType;
|
use App\Enums\Media\Type as MediaType;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Carbon\CarbonImmutable;
|
use Carbon\CarbonImmutable;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Illuminate\Support\Facades\URL;
|
use Illuminate\Support\Facades\URL;
|
||||||
|
|
@ -18,10 +20,21 @@
|
||||||
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits — see max_bytes_by_type (max_bytes is the overall ceiling, equal to the video cap). Returns an upload_token, upload_url, max_bytes, and max_bytes_by_type. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
|
#[Description('Issue a one-shot signed POST URL that lets the user upload a local file (image, video, or PDF document) directly to this workspace. Size caps match web/API media limits — see max_bytes_by_type (max_bytes is the overall ceiling, equal to the video cap). Returns an upload_token, upload_url, max_bytes, and max_bytes_by_type. Hand the URL to the user (e.g. as a curl command with `-F media=@path/to/file`) or to the MCP client. After upload, call AttachMediaFromUploadTool(post_id, upload_token) to attach the result to a post.')]
|
||||||
class RequestMediaUploadTool extends Tool
|
class RequestMediaUploadTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$user = $request->user();
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
$workspaceId = $user->current_workspace_id;
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to upload media.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspaceId = $workspace->id;
|
||||||
|
|
||||||
$ttlMinutes = (int) config('trypost.media.signed_upload_url_ttl_minutes');
|
$ttlMinutes = (int) config('trypost.media.signed_upload_url_ttl_minutes');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,9 @@
|
||||||
use App\Enums\Post\Status;
|
use App\Enums\Post\Status;
|
||||||
use App\Enums\PostPlatform\ContentType;
|
use App\Enums\PostPlatform\ContentType;
|
||||||
use App\Http\Resources\Api\PostResource;
|
use App\Http\Resources\Api\PostResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\Post;
|
use App\Models\Post;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Rules\ContentTypeCompatibleWithMedia;
|
use App\Rules\ContentTypeCompatibleWithMedia;
|
||||||
use App\Rules\ContentTypeMatchesPostPlatform;
|
use App\Rules\ContentTypeMatchesPostPlatform;
|
||||||
use App\Support\PostPlatformMetaRules;
|
use App\Support\PostPlatformMetaRules;
|
||||||
|
|
@ -26,17 +28,23 @@
|
||||||
#[Description('Update a draft post — content, media, scheduled_at, labels, and which platforms are enabled. Cannot edit a post that has already been published.')]
|
#[Description('Update a draft post — content, media, scheduled_at, labels, and which platforms are enabled. Cannot edit a post that has already been published.')]
|
||||||
class UpdatePostTool extends Tool
|
class UpdatePostTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
$workspace = $request->user()->currentWorkspace;
|
$workspace = $request->user()?->currentWorkspace;
|
||||||
|
$post = $workspace instanceof Workspace
|
||||||
$postId = data_get($request->all(), 'post_id');
|
? Post::where('workspace_id', $workspace->id)->find(data_get($request->all(), 'post_id'))
|
||||||
$post = is_string($postId) ? Post::where('workspace_id', $workspace->id)->find($postId) : null;
|
: null;
|
||||||
|
|
||||||
if (! $post) {
|
if (! $post) {
|
||||||
return Response::error('Post not found.');
|
return Response::error('Post not found.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($denied = $this->denyUnlessCan($request, 'update', $post, 'Not authorized to update this post.')) {
|
||||||
|
return $denied;
|
||||||
|
}
|
||||||
|
|
||||||
$status = data_get($request->all(), 'status');
|
$status = data_get($request->all(), 'status');
|
||||||
|
|
||||||
$validated = $request->validate(
|
$validated = $request->validate(
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
use App\Actions\Signature\CreateSignature;
|
use App\Actions\Signature\CreateSignature;
|
||||||
use App\Http\Resources\Api\SignatureResource;
|
use App\Http\Resources\Api\SignatureResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -16,14 +18,26 @@
|
||||||
#[Description('Create a new signature with a name and content (hashtags, links, custom text, etc.).')]
|
#[Description('Create a new signature with a name and content (hashtags, links, custom text, etc.).')]
|
||||||
class CreateSignatureTool extends Tool
|
class CreateSignatureTool extends Tool
|
||||||
{
|
{
|
||||||
public function handle(Request $request): ResponseFactory
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage signatures.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'content' => ['required', 'string'],
|
'content' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$signature = CreateSignature::execute($request->user()->currentWorkspace, $validated);
|
$signature = CreateSignature::execute($workspace, $validated);
|
||||||
|
|
||||||
return Response::structured((new SignatureResource($signature))->resolve());
|
return Response::structured((new SignatureResource($signature))->resolve());
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@
|
||||||
namespace App\Mcp\Tools\Signature;
|
namespace App\Mcp\Tools\Signature;
|
||||||
|
|
||||||
use App\Actions\Signature\DeleteSignature;
|
use App\Actions\Signature\DeleteSignature;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSignature;
|
use App\Models\WorkspaceSignature;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
|
|
@ -18,11 +20,23 @@
|
||||||
#[Description('Delete a signature permanently. This cannot be undone.')]
|
#[Description('Delete a signature permanently. This cannot be undone.')]
|
||||||
class DeleteSignatureTool extends Tool
|
class DeleteSignatureTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage signatures.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate(['signature_id' => ['required', 'string']]);
|
$validated = $request->validate(['signature_id' => ['required', 'string']]);
|
||||||
|
|
||||||
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
|
$signature = WorkspaceSignature::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'signature_id'));
|
->find(data_get($validated, 'signature_id'));
|
||||||
|
|
||||||
if (! $signature) {
|
if (! $signature) {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,8 @@
|
||||||
|
|
||||||
use App\Actions\Signature\UpdateSignature;
|
use App\Actions\Signature\UpdateSignature;
|
||||||
use App\Http\Resources\Api\SignatureResource;
|
use App\Http\Resources\Api\SignatureResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
|
use App\Models\Workspace;
|
||||||
use App\Models\WorkspaceSignature;
|
use App\Models\WorkspaceSignature;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
|
|
@ -17,15 +19,27 @@
|
||||||
#[Description('Update a signature name or content.')]
|
#[Description('Update a signature name or content.')]
|
||||||
class UpdateSignatureTool extends Tool
|
class UpdateSignatureTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage signatures.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'signature_id' => ['required', 'string'],
|
'signature_id' => ['required', 'string'],
|
||||||
'name' => ['required', 'string', 'max:255'],
|
'name' => ['required', 'string', 'max:255'],
|
||||||
'content' => ['required', 'string'],
|
'content' => ['required', 'string'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
|
$signature = WorkspaceSignature::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'signature_id'));
|
->find(data_get($validated, 'signature_id'));
|
||||||
|
|
||||||
if (! $signature) {
|
if (! $signature) {
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,9 @@
|
||||||
use App\Actions\SocialAccount\ListDiscordChannels;
|
use App\Actions\SocialAccount\ListDiscordChannels;
|
||||||
use App\Enums\SocialAccount\Platform;
|
use App\Enums\SocialAccount\Platform;
|
||||||
use App\Exceptions\PlatformUnavailableException;
|
use App\Exceptions\PlatformUnavailableException;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\SocialAccount;
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -18,13 +20,25 @@
|
||||||
#[Description('List Discord text/announcement channels the bot can post to for a connected Discord server. Use the returned channel id as platforms[].meta.channel_id when creating or updating a Discord post (required to publish).')]
|
#[Description('List Discord text/announcement channels the bot can post to for a connected Discord server. Use the returned channel id as platforms[].meta.channel_id when creating or updating a Discord post (required to publish).')]
|
||||||
class ListDiscordChannelsTool extends Tool
|
class ListDiscordChannelsTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage posts.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'account_id' => ['required', 'string', 'uuid'],
|
'account_id' => ['required', 'string', 'uuid'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id)
|
$account = SocialAccount::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'account_id'));
|
->find(data_get($validated, 'account_id'));
|
||||||
|
|
||||||
if (! $account) {
|
if (! $account) {
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,9 @@
|
||||||
use App\Enums\SocialAccount\Platform;
|
use App\Enums\SocialAccount\Platform;
|
||||||
use App\Exceptions\Social\PinterestPublishException;
|
use App\Exceptions\Social\PinterestPublishException;
|
||||||
use App\Exceptions\TokenExpiredException;
|
use App\Exceptions\TokenExpiredException;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\SocialAccount;
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -19,13 +21,25 @@
|
||||||
#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as platforms[].meta.board_id when creating or updating a Pinterest post (required to publish).')]
|
#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as platforms[].meta.board_id when creating or updating a Pinterest post (required to publish).')]
|
||||||
class ListPinterestBoardsTool extends Tool
|
class ListPinterestBoardsTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'createPost',
|
||||||
|
'Not authorized to manage posts.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'account_id' => ['required', 'string', 'uuid'],
|
'account_id' => ['required', 'string', 'uuid'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id)
|
$account = SocialAccount::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'account_id'));
|
->find(data_get($validated, 'account_id'));
|
||||||
|
|
||||||
if (! $account) {
|
if (! $account) {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,9 @@
|
||||||
|
|
||||||
use App\Actions\SocialAccount\ToggleSocialAccount;
|
use App\Actions\SocialAccount\ToggleSocialAccount;
|
||||||
use App\Http\Resources\Api\SocialAccountResource;
|
use App\Http\Resources\Api\SocialAccountResource;
|
||||||
|
use App\Mcp\Concerns\AuthorizesMcpTool;
|
||||||
use App\Models\SocialAccount;
|
use App\Models\SocialAccount;
|
||||||
|
use App\Models\Workspace;
|
||||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||||
use Laravel\Mcp\Request;
|
use Laravel\Mcp\Request;
|
||||||
use Laravel\Mcp\Response;
|
use Laravel\Mcp\Response;
|
||||||
|
|
@ -17,13 +19,25 @@
|
||||||
#[Description('Toggle a social account active/inactive. When inactive, the account is skipped during scheduled publishing. Returns the updated account.')]
|
#[Description('Toggle a social account active/inactive. When inactive, the account is skipped during scheduled publishing. Returns the updated account.')]
|
||||||
class ToggleSocialAccountTool extends Tool
|
class ToggleSocialAccountTool extends Tool
|
||||||
{
|
{
|
||||||
|
use AuthorizesMcpTool;
|
||||||
|
|
||||||
public function handle(Request $request): Response|ResponseFactory
|
public function handle(Request $request): Response|ResponseFactory
|
||||||
{
|
{
|
||||||
|
$workspace = $this->authorizeCurrentWorkspace(
|
||||||
|
$request,
|
||||||
|
'manageAccounts',
|
||||||
|
'Not authorized to manage social accounts.',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (! $workspace instanceof Workspace) {
|
||||||
|
return $workspace;
|
||||||
|
}
|
||||||
|
|
||||||
$validated = $request->validate([
|
$validated = $request->validate([
|
||||||
'account_id' => ['required', 'string', 'uuid'],
|
'account_id' => ['required', 'string', 'uuid'],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id)
|
$account = SocialAccount::where('workspace_id', $workspace->id)
|
||||||
->find(data_get($validated, 'account_id'));
|
->find(data_get($validated, 'account_id'));
|
||||||
|
|
||||||
if (! $account) {
|
if (! $account) {
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
namespace App\Models;
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Builder;
|
||||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||||
use Laravel\Passport\Token;
|
use Laravel\Passport\Token;
|
||||||
|
|
||||||
|
|
@ -41,4 +42,199 @@ public function workspace(): BelongsTo
|
||||||
{
|
{
|
||||||
return $this->belongsTo(Workspace::class);
|
return $this->belongsTo(Workspace::class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passport resolves the user model via the OAuth client's provider, which
|
||||||
|
* breaks eager-loading `user` (the relation is built on an empty token with
|
||||||
|
* no client). Tokens in TryPost always belong to App\Models\User.
|
||||||
|
*/
|
||||||
|
public function user(): BelongsTo
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Active OAuth grants used by MCP clients (excludes personal access API keys).
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeActiveMcpOAuth(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->mcpOAuth()
|
||||||
|
->where('revoked', false)
|
||||||
|
->where(function (Builder $expires): void {
|
||||||
|
$expires->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MCP OAuth grants that still represent a live or recoverable session
|
||||||
|
* (unexpired access token, or expired access with a live refresh token).
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeConnectedMcpOAuth(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->mcpOAuth()
|
||||||
|
->where('revoked', false)
|
||||||
|
->where(function (Builder $alive): void {
|
||||||
|
$alive
|
||||||
|
->where(function (Builder $expires): void {
|
||||||
|
$expires->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
})
|
||||||
|
->orWhereHas(
|
||||||
|
'refreshToken',
|
||||||
|
fn (Builder $refresh): Builder => $refresh
|
||||||
|
->where('revoked', false)
|
||||||
|
->where(function (Builder $refreshExpires): void {
|
||||||
|
$refreshExpires->whereNull('expires_at')
|
||||||
|
->orWhere('expires_at', '>', now());
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopeMcpOAuth(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query
|
||||||
|
->whereJsonContains('scopes', 'mcp:use')
|
||||||
|
->whereHas(
|
||||||
|
'client',
|
||||||
|
fn (Builder $client): Builder => $client
|
||||||
|
->where('revoked', false)
|
||||||
|
->whereJsonDoesntContain('grant_types', 'personal_access'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Personal-access API keys (REST), excluding MCP OAuth clients.
|
||||||
|
*
|
||||||
|
* @param Builder<static> $query
|
||||||
|
* @return Builder<static>
|
||||||
|
*/
|
||||||
|
public function scopePersonalAccessApiKey(Builder $query): Builder
|
||||||
|
{
|
||||||
|
return $query->whereHas(
|
||||||
|
'client',
|
||||||
|
fn (Builder $client): Builder => $client
|
||||||
|
->where('revoked', false)
|
||||||
|
->whereJsonContains('grant_types', 'personal_access'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this token was issued by a live personal-access client (REST API keys).
|
||||||
|
*/
|
||||||
|
public function isPersonalAccessToken(): bool
|
||||||
|
{
|
||||||
|
$this->loadMissing('client');
|
||||||
|
|
||||||
|
return $this->client !== null
|
||||||
|
&& ! $this->client->revoked
|
||||||
|
&& $this->client->hasGrantType('personal_access');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is a non-revoked MCP OAuth grant with mcp:use (ignores expiry).
|
||||||
|
*/
|
||||||
|
public function isMcpOAuthGrant(): bool
|
||||||
|
{
|
||||||
|
$this->loadMissing('client');
|
||||||
|
|
||||||
|
if ($this->revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! in_array('mcp:use', $this->scopes ?? [], true)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->client !== null
|
||||||
|
&& ! $this->client->revoked
|
||||||
|
&& ! $this->client->hasGrantType('personal_access');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this is a non-revoked, unexpired MCP OAuth grant with mcp:use.
|
||||||
|
*/
|
||||||
|
public function isActiveMcpGrant(): bool
|
||||||
|
{
|
||||||
|
if (! $this->isMcpOAuthGrant()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->expires_at === null || ! $this->expires_at->isPast();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether a refresh token can still mint a new access token for this grant.
|
||||||
|
*/
|
||||||
|
public function hasLiveRefreshToken(): bool
|
||||||
|
{
|
||||||
|
$this->loadMissing('refreshToken');
|
||||||
|
|
||||||
|
$refresh = $this->refreshToken;
|
||||||
|
|
||||||
|
if ($refresh === null || $refresh->revoked) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $refresh->expires_at === null || $refresh->expires_at->isFuture();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this MCP grant can actually use the product (active token + a
|
||||||
|
* workspace the owner can view — write tools enforce createPost themselves).
|
||||||
|
*/
|
||||||
|
public function isUsableMcpGrant(?User $user = null, ?Workspace $workspace = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->isActiveMcpGrant()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->ownerCanViewWorkspace($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether this MCP grant should appear in the connected-clients list
|
||||||
|
* (usable now, or recoverable via refresh, for a user who can view a workspace).
|
||||||
|
*/
|
||||||
|
public function isListedMcpConnection(?User $user = null, ?Workspace $workspace = null): bool
|
||||||
|
{
|
||||||
|
if (! $this->isMcpOAuthGrant()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $this->isActiveMcpGrant() && ! $this->hasLiveRefreshToken()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->ownerCanViewWorkspace($user, $workspace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function ownerCanViewWorkspace(?User $user = null, ?Workspace $workspace = null): bool
|
||||||
|
{
|
||||||
|
$user ??= User::query()
|
||||||
|
->with('currentWorkspace')
|
||||||
|
->find($this->user_id);
|
||||||
|
|
||||||
|
if (! $user instanceof User) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$workspace ??= $this->workspace ?? $user->currentWorkspace;
|
||||||
|
|
||||||
|
return $workspace instanceof Workspace
|
||||||
|
&& $user->can('view', $workspace);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,22 @@ public function hasActiveSubscription(): bool
|
||||||
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
return $this->subscribed(self::SUBSCRIPTION_NAME);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account may use the app (active subscription, or a generic
|
||||||
|
* trial when REQUIRE_CARD_FOR_TRIAL is disabled).
|
||||||
|
*/
|
||||||
|
public function hasAppAccess(): bool
|
||||||
|
{
|
||||||
|
if (config('trypost.self_hosted')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
||||||
|
|
||||||
|
return $this->subscribed(self::SUBSCRIPTION_NAME)
|
||||||
|
|| (! $requiresCardForTrial && $this->isOnTrial());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Align the Stripe subscription quantity with the number of workspaces the
|
* Align the Stripe subscription quantity with the number of workspaces the
|
||||||
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
|
* account owns. Each workspace is a billed unit. No-op in self-hosted mode
|
||||||
|
|
|
||||||
|
|
@ -32,12 +32,7 @@ public function useAi(User $user, Account $account): Response
|
||||||
return Response::allow();
|
return Response::allow();
|
||||||
}
|
}
|
||||||
|
|
||||||
$requiresCardForTrial = (bool) config('trypost.billing.require_card_for_trial', true);
|
if (! $account->hasAppAccess()) {
|
||||||
|
|
||||||
$hasAccess = $account->subscribed(Account::SUBSCRIPTION_NAME)
|
|
||||||
|| (! $requiresCardForTrial && $account->isOnTrial());
|
|
||||||
|
|
||||||
if (! $hasAccess) {
|
|
||||||
return Response::deny(__('billing.flash.subscription_required'));
|
return Response::deny(__('billing.flash.subscription_required'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,11 @@ protected function configurePassport(): void
|
||||||
{
|
{
|
||||||
Passport::useTokenModel(AccessToken::class);
|
Passport::useTokenModel(AccessToken::class);
|
||||||
|
|
||||||
|
// API keys may omit an application expiry ("never"). Passport still
|
||||||
|
// embeds a JWT `exp`, so keep that far ahead and enforce optional
|
||||||
|
// `oauth_access_tokens.expires_at` in LoadWorkspaceFromToken.
|
||||||
|
Passport::personalAccessTokensExpireIn(now()->addYears(100));
|
||||||
|
|
||||||
Passport::tokensCan([
|
Passport::tokensCan([
|
||||||
'mcp:use' => 'Use MCP server',
|
'mcp:use' => 'Use MCP server',
|
||||||
]);
|
]);
|
||||||
|
|
@ -160,6 +165,11 @@ protected function configureRateLimiting(): void
|
||||||
return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip());
|
return Limit::perMinute(60)->by($request->workspace?->id ?: $request->ip());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
RateLimiter::for(
|
||||||
|
'mcp-oauth-registration',
|
||||||
|
fn (Request $request): Limit => Limit::perMinute(30)->by($request->ip()),
|
||||||
|
);
|
||||||
|
|
||||||
// Signed media uploads (api.uploads.store). MCP hosts share egress IPs
|
// Signed media uploads (api.uploads.store). MCP hosts share egress IPs
|
||||||
// across tenants — key by workspace_id from the signed URL, with a high
|
// across tenants — key by workspace_id from the signed URL, with a high
|
||||||
// IP backstop so one client cannot flood every workspace.
|
// IP backstop so one client cannot flood every workspace.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
"nightwatch": true,
|
"nightwatch": true,
|
||||||
"sail": false,
|
"sail": false,
|
||||||
"skills": [
|
"skills": [
|
||||||
|
"infer-conventions",
|
||||||
"ai-sdk-development",
|
"ai-sdk-development",
|
||||||
"cashier-stripe-development",
|
"cashier-stripe-development",
|
||||||
"laravel-best-practices",
|
"laravel-best-practices",
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,16 @@ services:
|
||||||
REVERB_PORT: "8080"
|
REVERB_PORT: "8080"
|
||||||
REVERB_SCHEME: http
|
REVERB_SCHEME: http
|
||||||
|
|
||||||
|
# ===== Passport (OAuth / API keys / MCP) =====
|
||||||
|
# REQUIRED for any durable deploy (and always for multi-node / load
|
||||||
|
# balancers). File keys under storage/ are NOT persisted by the volumes
|
||||||
|
# below — without these env vars a container recreate issues new keys and
|
||||||
|
# invalidates every API/MCP token. Generate once:
|
||||||
|
# docker compose -f compose.prod.yaml run --rm app php artisan passport:keys --show
|
||||||
|
# then paste the PEM contents here (use \n for newlines).
|
||||||
|
PASSPORT_PRIVATE_KEY: ""
|
||||||
|
PASSPORT_PUBLIC_KEY: ""
|
||||||
|
|
||||||
# ===== Storage =====
|
# ===== Storage =====
|
||||||
# Default: local disk, persisted in the "storage" volume below.
|
# Default: local disk, persisted in the "storage" volume below.
|
||||||
FILESYSTEM_DISK: public
|
FILESYSTEM_DISK: public
|
||||||
|
|
|
||||||
|
|
@ -38,11 +38,11 @@
|
||||||
"inertiajs/inertia-laravel": "^3.0",
|
"inertiajs/inertia-laravel": "^3.0",
|
||||||
"intervention/image": "^4.0",
|
"intervention/image": "^4.0",
|
||||||
"laravel/ai": "^0.5.1",
|
"laravel/ai": "^0.5.1",
|
||||||
"laravel/boost": "^2.0",
|
"laravel/boost": "^2.5",
|
||||||
"laravel/cashier": "^16.2",
|
"laravel/cashier": "^16.2",
|
||||||
"laravel/framework": "^13.0",
|
"laravel/framework": "^13.0",
|
||||||
"laravel/horizon": "^5.45",
|
"laravel/horizon": "^5.45",
|
||||||
"laravel/mcp": "^0.6.4",
|
"laravel/mcp": "^0.9.1",
|
||||||
"laravel/nightwatch": "^1.22",
|
"laravel/nightwatch": "^1.22",
|
||||||
"laravel/passport": "^13.7",
|
"laravel/passport": "^13.7",
|
||||||
"laravel/reverb": "^1.0",
|
"laravel/reverb": "^1.0",
|
||||||
|
|
|
||||||
199
composer.lock
generated
199
composer.lock
generated
|
|
@ -4,7 +4,7 @@
|
||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "84ccdb211d510192123d0d7ccd477d55",
|
"content-hash": "d2abd6288d12131200c2297189b1f11f",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "aws/aws-crt-php",
|
"name": "aws/aws-crt-php",
|
||||||
|
|
@ -415,6 +415,83 @@
|
||||||
],
|
],
|
||||||
"time": "2025-01-03T16:18:33+00:00"
|
"time": "2025-01-03T16:18:33+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "composer/semver",
|
||||||
|
"version": "3.4.4",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/composer/semver.git",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/composer/semver/zipball/198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"reference": "198166618906cb2de69b95d7d47e5fa8aa1b2b95",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": "^5.3.2 || ^7.0 || ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpstan/phpstan": "^1.11",
|
||||||
|
"symfony/phpunit-bridge": "^3 || ^7"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-main": "3.x-dev"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Composer\\Semver\\": "src"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Nils Adermann",
|
||||||
|
"email": "naderman@naderman.de",
|
||||||
|
"homepage": "http://www.naderman.de"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Jordi Boggiano",
|
||||||
|
"email": "j.boggiano@seld.be",
|
||||||
|
"homepage": "http://seld.be"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Rob Bast",
|
||||||
|
"email": "rob.bast@gmail.com",
|
||||||
|
"homepage": "http://robbast.nl"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Semver library that offers utilities, version constraint parsing and validation.",
|
||||||
|
"keywords": [
|
||||||
|
"semantic",
|
||||||
|
"semver",
|
||||||
|
"validation",
|
||||||
|
"versioning"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"irc": "ircs://irc.libera.chat:6697/composer",
|
||||||
|
"issues": "https://github.com/composer/semver/issues",
|
||||||
|
"source": "https://github.com/composer/semver/tree/3.4.4"
|
||||||
|
},
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"url": "https://packagist.com",
|
||||||
|
"type": "custom"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "https://github.com/composer",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"time": "2025-08-20T19:15:30+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "defuse/php-encryption",
|
"name": "defuse/php-encryption",
|
||||||
"version": "v2.4.0",
|
"version": "v2.4.0",
|
||||||
|
|
@ -1280,21 +1357,21 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/guzzle",
|
"name": "guzzlehttp/guzzle",
|
||||||
"version": "7.15.2",
|
"version": "7.15.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/guzzle.git",
|
"url": "https://github.com/guzzle/guzzle.git",
|
||||||
"reference": "744101956d78b7c1384d0cbf379db13e859167bf"
|
"reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/744101956d78b7c1384d0cbf379db13e859167bf",
|
"url": "https://api.github.com/repos/guzzle/guzzle/zipball/ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc",
|
||||||
"reference": "744101956d78b7c1384d0cbf379db13e859167bf",
|
"reference": "ae311b8f045ea93ce7b1c9cdb7cec06c53f944bc",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"guzzlehttp/promises": "^2.5.1",
|
"guzzlehttp/promises": "^2.5.2",
|
||||||
"guzzlehttp/psr7": "^2.13",
|
"guzzlehttp/psr7": "^2.13",
|
||||||
"php": "^7.2.5 || ^8.0",
|
"php": "^7.2.5 || ^8.0",
|
||||||
"psr/http-client": "^1.0",
|
"psr/http-client": "^1.0",
|
||||||
|
|
@ -1388,7 +1465,7 @@
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/guzzle/issues",
|
"issues": "https://github.com/guzzle/guzzle/issues",
|
||||||
"source": "https://github.com/guzzle/guzzle/tree/7.15.2"
|
"source": "https://github.com/guzzle/guzzle/tree/7.15.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -1404,20 +1481,20 @@
|
||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-26T23:23:20+00:00"
|
"time": "2026-08-05T19:48:21+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/promises",
|
"name": "guzzlehttp/promises",
|
||||||
"version": "2.5.1",
|
"version": "2.5.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/guzzle/promises.git",
|
"url": "https://github.com/guzzle/promises.git",
|
||||||
"reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29"
|
"reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/guzzle/promises/zipball/9ad1e4fc607446a055b95870c7f668e93b5cff29",
|
"url": "https://api.github.com/repos/guzzle/promises/zipball/2823687acff28b2dbe67b2508a6b300e2c3fa4ce",
|
||||||
"reference": "9ad1e4fc607446a055b95870c7f668e93b5cff29",
|
"reference": "2823687acff28b2dbe67b2508a6b300e2c3fa4ce",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -1472,7 +1549,7 @@
|
||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/guzzle/promises/issues",
|
"issues": "https://github.com/guzzle/promises/issues",
|
||||||
"source": "https://github.com/guzzle/promises/tree/2.5.1"
|
"source": "https://github.com/guzzle/promises/tree/2.5.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -1488,7 +1565,7 @@
|
||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-08T15:48:39+00:00"
|
"time": "2026-08-05T19:30:54+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "guzzlehttp/psr7",
|
"name": "guzzlehttp/psr7",
|
||||||
|
|
@ -1982,16 +2059,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/boost",
|
"name": "laravel/boost",
|
||||||
"version": "v2.4.8",
|
"version": "v2.5.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/boost.git",
|
"url": "https://github.com/laravel/boost.git",
|
||||||
"reference": "d11d720cf9537f8d236a11d973e99563a598ec9c"
|
"reference": "f6b054dcbc0aacf1d187128edf7d917c6d99792a"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/boost/zipball/d11d720cf9537f8d236a11d973e99563a598ec9c",
|
"url": "https://api.github.com/repos/laravel/boost/zipball/f6b054dcbc0aacf1d187128edf7d917c6d99792a",
|
||||||
"reference": "d11d720cf9537f8d236a11d973e99563a598ec9c",
|
"reference": "f6b054dcbc0aacf1d187128edf7d917c6d99792a",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -2000,9 +2077,9 @@
|
||||||
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/contracts": "^11.45.3|^12.41.1|^13.0",
|
||||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||||
"laravel/mcp": "^0.5.1|^0.6.0|~0.7.0,<0.7.1",
|
"laravel/mcp": "^0.7.1|^0.8.0|^0.9.0",
|
||||||
"laravel/prompts": "^0.3.10",
|
"laravel/prompts": "^0.3.10",
|
||||||
"laravel/roster": "^0.5.0",
|
"laravel/roster": "^1.0.0",
|
||||||
"php": "^8.2"
|
"php": "^8.2"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
|
|
@ -2044,7 +2121,7 @@
|
||||||
"issues": "https://github.com/laravel/boost/issues",
|
"issues": "https://github.com/laravel/boost/issues",
|
||||||
"source": "https://github.com/laravel/boost"
|
"source": "https://github.com/laravel/boost"
|
||||||
},
|
},
|
||||||
"time": "2026-05-19T20:09:50+00:00"
|
"time": "2026-08-04T21:10:39+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/cashier",
|
"name": "laravel/cashier",
|
||||||
|
|
@ -2137,16 +2214,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/framework",
|
"name": "laravel/framework",
|
||||||
"version": "v13.23.0",
|
"version": "v13.24.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/framework.git",
|
"url": "https://github.com/laravel/framework.git",
|
||||||
"reference": "92a707229148e57f08a249211c8a5a194159c619"
|
"reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/framework/zipball/92a707229148e57f08a249211c8a5a194159c619",
|
"url": "https://api.github.com/repos/laravel/framework/zipball/6d481710375d2aa67656922ef760cdd2b18bcfe0",
|
||||||
"reference": "92a707229148e57f08a249211c8a5a194159c619",
|
"reference": "6d481710375d2aa67656922ef760cdd2b18bcfe0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -2166,7 +2243,7 @@
|
||||||
"guzzlehttp/guzzle": "^7.8.2",
|
"guzzlehttp/guzzle": "^7.8.2",
|
||||||
"guzzlehttp/promises": "^2.0.3",
|
"guzzlehttp/promises": "^2.0.3",
|
||||||
"guzzlehttp/uri-template": "^1.0",
|
"guzzlehttp/uri-template": "^1.0",
|
||||||
"laravel/prompts": "^0.3.0",
|
"laravel/prompts": "^0.3.11",
|
||||||
"laravel/serializable-closure": "^2.0.10",
|
"laravel/serializable-closure": "^2.0.10",
|
||||||
"league/commonmark": "^2.8.1",
|
"league/commonmark": "^2.8.1",
|
||||||
"league/flysystem": "^3.25.1",
|
"league/flysystem": "^3.25.1",
|
||||||
|
|
@ -2360,7 +2437,7 @@
|
||||||
"issues": "https://github.com/laravel/framework/issues",
|
"issues": "https://github.com/laravel/framework/issues",
|
||||||
"source": "https://github.com/laravel/framework"
|
"source": "https://github.com/laravel/framework"
|
||||||
},
|
},
|
||||||
"time": "2026-07-27T14:48:58+00:00"
|
"time": "2026-08-04T15:54:59+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/horizon",
|
"name": "laravel/horizon",
|
||||||
|
|
@ -2444,16 +2521,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/mcp",
|
"name": "laravel/mcp",
|
||||||
"version": "v0.6.7",
|
"version": "v0.9.1",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/mcp.git",
|
"url": "https://github.com/laravel/mcp.git",
|
||||||
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2"
|
"reference": "a08884d79a95c5143498507aec5badf751cdbec4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/mcp/zipball/c3775e57b95d7eadb580d543689d9971ec8721f2",
|
"url": "https://api.github.com/repos/laravel/mcp/zipball/a08884d79a95c5143498507aec5badf751cdbec4",
|
||||||
"reference": "c3775e57b95d7eadb580d543689d9971ec8721f2",
|
"reference": "a08884d79a95c5143498507aec5badf751cdbec4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -2467,7 +2544,8 @@
|
||||||
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/routing": "^11.45.3|^12.41.1|^13.0",
|
||||||
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/support": "^11.45.3|^12.41.1|^13.0",
|
||||||
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
"illuminate/validation": "^11.45.3|^12.41.1|^13.0",
|
||||||
"php": "^8.2"
|
"php": "^8.2",
|
||||||
|
"symfony/process": "^7.4.5|^8.0.5"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pint": "^1.20",
|
"laravel/pint": "^1.20",
|
||||||
|
|
@ -2480,7 +2558,7 @@
|
||||||
"extra": {
|
"extra": {
|
||||||
"laravel": {
|
"laravel": {
|
||||||
"aliases": {
|
"aliases": {
|
||||||
"Mcp": "Laravel\\Mcp\\Server\\Facades\\Mcp"
|
"Mcp": "Laravel\\Mcp\\Facades\\Mcp"
|
||||||
},
|
},
|
||||||
"providers": [
|
"providers": [
|
||||||
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
"Laravel\\Mcp\\Server\\McpServiceProvider"
|
||||||
|
|
@ -2513,7 +2591,7 @@
|
||||||
"issues": "https://github.com/laravel/mcp/issues",
|
"issues": "https://github.com/laravel/mcp/issues",
|
||||||
"source": "https://github.com/laravel/mcp"
|
"source": "https://github.com/laravel/mcp"
|
||||||
},
|
},
|
||||||
"time": "2026-04-15T08:30:42+00:00"
|
"time": "2026-07-21T13:23:52+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/nightwatch",
|
"name": "laravel/nightwatch",
|
||||||
|
|
@ -2686,16 +2764,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/prompts",
|
"name": "laravel/prompts",
|
||||||
"version": "v0.3.21",
|
"version": "v0.3.22",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/prompts.git",
|
"url": "https://github.com/laravel/prompts.git",
|
||||||
"reference": "7753c65c281c2550c7c183f14e18062073b7d821"
|
"reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/prompts/zipball/7753c65c281c2550c7c183f14e18062073b7d821",
|
"url": "https://api.github.com/repos/laravel/prompts/zipball/02b89b39e8972a998db4d5d4ad4719239dd4aee4",
|
||||||
"reference": "7753c65c281c2550c7c183f14e18062073b7d821",
|
"reference": "02b89b39e8972a998db4d5d4ad4719239dd4aee4",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -2739,9 +2817,9 @@
|
||||||
"description": "Add beautiful and user-friendly forms to your command-line applications.",
|
"description": "Add beautiful and user-friendly forms to your command-line applications.",
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/laravel/prompts/issues",
|
"issues": "https://github.com/laravel/prompts/issues",
|
||||||
"source": "https://github.com/laravel/prompts/tree/v0.3.21"
|
"source": "https://github.com/laravel/prompts/tree/v0.3.22"
|
||||||
},
|
},
|
||||||
"time": "2026-06-26T00:11:25+00:00"
|
"time": "2026-08-04T14:50:50+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/reverb",
|
"name": "laravel/reverb",
|
||||||
|
|
@ -2824,32 +2902,33 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/roster",
|
"name": "laravel/roster",
|
||||||
"version": "v0.5.1",
|
"version": "v1.0.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/laravel/roster.git",
|
"url": "https://github.com/laravel/roster.git",
|
||||||
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb"
|
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/laravel/roster/zipball/5089de7615f72f78e831590ff9d0435fed0102bb",
|
"url": "https://api.github.com/repos/laravel/roster/zipball/89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
|
||||||
"reference": "5089de7615f72f78e831590ff9d0435fed0102bb",
|
"reference": "89e518bd88ae98ff50f6082f6b517c8d8e8245fa",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
"composer/semver": "^3.0",
|
||||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||||
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
"illuminate/contracts": "^11.0|^12.0|^13.0",
|
||||||
"illuminate/routing": "^11.0|^12.0|^13.0",
|
|
||||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||||
"php": "^8.2",
|
"php": "^8.2",
|
||||||
"symfony/yaml": "^7.2|^8.0"
|
"symfony/yaml": "^7.2|^8.0"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"laravel/pint": "^1.14",
|
"laravel/pint": "^1.29",
|
||||||
"mockery/mockery": "^1.6",
|
"mockery/mockery": "^1.6",
|
||||||
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
||||||
"pestphp/pest": "^3.0|^4.1",
|
"pestphp/pest": "^3.0|^4.1",
|
||||||
"phpstan/phpstan": "^2.0"
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"rector/rector": "^2.0"
|
||||||
},
|
},
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
|
|
@ -2881,7 +2960,7 @@
|
||||||
"issues": "https://github.com/laravel/roster/issues",
|
"issues": "https://github.com/laravel/roster/issues",
|
||||||
"source": "https://github.com/laravel/roster"
|
"source": "https://github.com/laravel/roster"
|
||||||
},
|
},
|
||||||
"time": "2026-03-05T07:58:43+00:00"
|
"time": "2026-07-18T17:53:15+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "laravel/sentinel",
|
"name": "laravel/sentinel",
|
||||||
|
|
@ -3404,16 +3483,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/commonmark",
|
"name": "league/commonmark",
|
||||||
"version": "2.8.3",
|
"version": "2.9.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/thephpleague/commonmark.git",
|
"url": "https://github.com/thephpleague/commonmark.git",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
"reference": "5703d83ba3da3b2e356a5fedc848ed6d8ffb6529",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -3450,7 +3529,7 @@
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"extra": {
|
"extra": {
|
||||||
"branch-alias": {
|
"branch-alias": {
|
||||||
"dev-main": "2.9-dev"
|
"dev-main": "2.10-dev"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|
@ -3507,7 +3586,7 @@
|
||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-12T15:29:16+00:00"
|
"time": "2026-08-03T13:42:31+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "league/config",
|
"name": "league/config",
|
||||||
|
|
@ -10618,16 +10697,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "symfony/yaml",
|
"name": "symfony/yaml",
|
||||||
"version": "v8.1.0",
|
"version": "v8.1.2",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/symfony/yaml.git",
|
"url": "https://github.com/symfony/yaml.git",
|
||||||
"reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0"
|
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/efb42bd2c6f4f3ccfd4683583449938b5fc146b0",
|
"url": "https://api.github.com/repos/symfony/yaml/zipball/faabdbe998e8c5c599dceffa27aa265b185c0736",
|
||||||
"reference": "efb42bd2c6f4f3ccfd4683583449938b5fc146b0",
|
"reference": "faabdbe998e8c5c599dceffa27aa265b185c0736",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
|
|
@ -10670,7 +10749,7 @@
|
||||||
"description": "Loads and dumps YAML files",
|
"description": "Loads and dumps YAML files",
|
||||||
"homepage": "https://symfony.com",
|
"homepage": "https://symfony.com",
|
||||||
"support": {
|
"support": {
|
||||||
"source": "https://github.com/symfony/yaml/tree/v8.1.0"
|
"source": "https://github.com/symfony/yaml/tree/v8.1.2"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
|
|
@ -10690,7 +10769,7 @@
|
||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-05-29T05:06:50+00:00"
|
"time": "2026-07-22T15:42:13+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "tijsverkoyen/css-to-inline-styles",
|
"name": "tijsverkoyen/css-to-inline-styles",
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ class DatabaseSeeder extends Seeder
|
||||||
public function run(): void
|
public function run(): void
|
||||||
{
|
{
|
||||||
$this->call([
|
$this->call([
|
||||||
PlanSeeder::class,
|
|
||||||
PassportSeeder::class,
|
PassportSeeder::class,
|
||||||
|
PlanSeeder::class,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,20 +5,24 @@
|
||||||
namespace Database\Seeders;
|
namespace Database\Seeders;
|
||||||
|
|
||||||
use Illuminate\Database\Seeder;
|
use Illuminate\Database\Seeder;
|
||||||
|
use Illuminate\Support\Facades\Cache;
|
||||||
use Laravel\Passport\ClientRepository;
|
use Laravel\Passport\ClientRepository;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
class PassportSeeder extends Seeder
|
class PassportSeeder extends Seeder
|
||||||
{
|
{
|
||||||
public function run(ClientRepository $clients): void
|
public function run(ClientRepository $clients): void
|
||||||
{
|
{
|
||||||
|
Cache::lock('passport:personal-access-client', 30)->block(10, function () use ($clients): void {
|
||||||
try {
|
try {
|
||||||
$clients->personalAccessClient('users');
|
$clients->personalAccessClient('users');
|
||||||
|
|
||||||
return;
|
return;
|
||||||
} catch (\RuntimeException) {
|
} catch (RuntimeException) {
|
||||||
// No client yet — fall through to create.
|
// No client yet — fall through to create.
|
||||||
}
|
}
|
||||||
|
|
||||||
$clients->createPersonalAccessGrantClient(name: 'TryPost Personal Access Client');
|
$clients->createPersonalAccessGrantClient(name: 'TryPost Personal Access Client');
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,17 @@ cd /var/www/html
|
||||||
|
|
||||||
TARGET="${TRYPOST_TARGET:-dev}"
|
TARGET="${TRYPOST_TARGET:-dev}"
|
||||||
|
|
||||||
|
# One-off commands from `docker compose run app ...` must bypass the long-lived
|
||||||
|
# application bootstrap and execute exactly as requested.
|
||||||
|
if [ "$#" -gt 0 ]; then
|
||||||
|
exec "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${TARGET}" = "production" ] && [ -z "${APP_KEY:-}" ]; then
|
||||||
|
echo "[entrypoint] APP_KEY is required in production" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 1) Bootstrap .env from the Docker template on first dev boot. The bind-mount
|
# 1) Bootstrap .env from the Docker template on first dev boot. The bind-mount
|
||||||
# in dev hides /var/www/html/.env.docker.example, so prefer docker/ first.
|
# in dev hides /var/www/html/.env.docker.example, so prefer docker/ first.
|
||||||
if [ "${TRYPOST_DOCKER_BOOTSTRAP:-0}" = "1" ] && [ ! -f .env ]; then
|
if [ "${TRYPOST_DOCKER_BOOTSTRAP:-0}" = "1" ] && [ ! -f .env ]; then
|
||||||
|
|
@ -65,7 +76,7 @@ done
|
||||||
|
|
||||||
# 7) Run migrations (graceful: succeeds even when nothing to migrate).
|
# 7) Run migrations (graceful: succeeds even when nothing to migrate).
|
||||||
echo "[entrypoint] running migrations"
|
echo "[entrypoint] running migrations"
|
||||||
php artisan migrate --force --graceful || true
|
php artisan migrate --force
|
||||||
|
|
||||||
# 8) storage:link if missing.
|
# 8) storage:link if missing.
|
||||||
if [ ! -L public/storage ]; then
|
if [ ! -L public/storage ]; then
|
||||||
|
|
@ -73,17 +84,31 @@ if [ ! -L public/storage ]; then
|
||||||
php artisan storage:link --force || true
|
php artisan storage:link --force || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 9) Passport keys on first boot.
|
# 9) Passport keys. Prefer PASSPORT_PRIVATE_KEY / PASSPORT_PUBLIC_KEY from
|
||||||
if [ ! -f storage/oauth-private.key ]; then
|
# the environment (required for durable / multi-node deploys — storage/oauth-*
|
||||||
echo "[entrypoint] generating Passport keys"
|
# is not on a persisted volume in compose.prod.yaml). Fall back to generating
|
||||||
php artisan passport:keys --force || true
|
# files under storage/ only for local/dev when those env vars are unset.
|
||||||
|
if [ -n "${PASSPORT_PRIVATE_KEY:-}" ] && [ -n "${PASSPORT_PUBLIC_KEY:-}" ]; then
|
||||||
|
echo "[entrypoint] using Passport keys from environment"
|
||||||
|
elif [ "${TRYPOST_TARGET:-}" = "production" ] || [ "${APP_ENV:-}" = "production" ]; then
|
||||||
|
echo "[entrypoint] ERROR: PASSPORT_PRIVATE_KEY and PASSPORT_PUBLIC_KEY must be set in production." >&2
|
||||||
|
echo "[entrypoint] Generate once with: php artisan passport:keys --show" >&2
|
||||||
|
exit 1
|
||||||
|
elif [ ! -f storage/oauth-private.key ] || [ ! -f storage/oauth-public.key ]; then
|
||||||
|
echo "[entrypoint] generating Passport keys (dev fallback)"
|
||||||
|
php artisan passport:keys --force
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 10) Wayfinder TS regen — Vite needs the files before it boots.
|
# 10) Personal access client for REST API keys. The seeder is idempotent, so
|
||||||
|
# fresh self-hosted installs and existing deployments are both safe.
|
||||||
|
echo "[entrypoint] ensuring Passport personal access client"
|
||||||
|
php artisan db:seed --class='Database\Seeders\PassportSeeder' --force
|
||||||
|
|
||||||
|
# 11) Wayfinder TS regen — Vite needs the files before it boots.
|
||||||
echo "[entrypoint] regenerating wayfinder helpers"
|
echo "[entrypoint] regenerating wayfinder helpers"
|
||||||
php artisan wayfinder:generate --with-form || true
|
php artisan wayfinder:generate --with-form || true
|
||||||
|
|
||||||
# 11) Cache strategy: prod = pre-cache; dev = clear.
|
# 12) Cache strategy: prod = pre-cache; dev = clear.
|
||||||
if [ "${TARGET}" = "production" ]; then
|
if [ "${TARGET}" = "production" ]; then
|
||||||
php artisan config:cache
|
php artisan config:cache
|
||||||
php artisan route:cache
|
php artisan route:cache
|
||||||
|
|
@ -96,7 +121,7 @@ else
|
||||||
php artisan event:clear
|
php artisan event:clear
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 12) Permissions. Production php-fpm pool runs as www-data (Alpine default),
|
# 13) Permissions. Production php-fpm pool runs as www-data (Alpine default),
|
||||||
# so storage and bootstrap/cache must be writable by that user — Laravel
|
# so storage and bootstrap/cache must be writable by that user — Laravel
|
||||||
# needs to write session files, view cache, log files, etc.
|
# needs to write session files, view cache, log files, etc.
|
||||||
if [ "${TARGET}" = "production" ]; then
|
if [ "${TARGET}" = "production" ]; then
|
||||||
|
|
|
||||||
47
lang/ar/mcp.php
Normal file
47
lang/ar/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'اربط مساعدي الذكاء الاصطناعي لإنشاء المنشورات وإدارتها بحساب TryPost الخاص بك.',
|
||||||
|
'copy_step' => 'انسخ عنوان خادم TryPost',
|
||||||
|
'open_step' => 'افتح مساعد الذكاء الاصطناعي',
|
||||||
|
'copy' => 'نسخ الرابط',
|
||||||
|
'connect' => 'الاتصال عبر :client',
|
||||||
|
'step_add' => 'الصق الاسم أو الرابط أو الإعداد أدناه في تطبيقك. يفتح تسجيل الدخول في المتصفح عند أول اتصال.',
|
||||||
|
'name_label' => 'الاسم',
|
||||||
|
'url_label' => 'رابط الخادم',
|
||||||
|
'config_label' => 'الإعداد',
|
||||||
|
'connected_title' => 'التطبيقات المتصلة',
|
||||||
|
'connected_description' => 'المساعدون الذين سجّلت الدخول إليهم. يمكنك قطع اتصال ما لم تعد تستخدمه.',
|
||||||
|
'connected_empty' => 'لا يوجد اتصال بعد. استخدم Claude أو ChatGPT أو عميلًا آخر أعلاه.',
|
||||||
|
'disconnect' => 'قطع الاتصال',
|
||||||
|
'disconnect_title' => 'قطع اتصال التطبيق',
|
||||||
|
'disconnect_confirm' => 'يؤدي هذا إلى تسجيل خروج التطبيق من TryPost. سيحتاج إلى إعادة الاتصال قبل استخدام MCP مجددًا.',
|
||||||
|
'disconnected' => 'تم قطع اتصال التطبيق.',
|
||||||
|
'copied' => 'تم النسخ',
|
||||||
|
'last_used' => 'آخر استخدام',
|
||||||
|
'never' => 'أبدًا',
|
||||||
|
'documentation_title' => 'التوثيق',
|
||||||
|
'documentation_description' => 'أدلة الإعداد لكل عميل، والأدوات المتاحة، وحل المشكلات.',
|
||||||
|
'view_docs' => 'عرض التوثيق',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'تطبيقات أخرى',
|
||||||
|
'other_clients_description' => 'Cursor وVS Code وClaude Code وأي تطبيق يدعم MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'افتح Settings → Connectors، أضِف موصلًا مخصصًا، ثم الصق الرابط أعلاه.',
|
||||||
|
'chatgpt' => 'افتح Settings → Apps & Connectors، أنشئ موصلًا مخصصًا، ثم الصق الرابط أعلاه.',
|
||||||
|
'cursor' => 'أضف TryPost كخادم MCP بعيد في Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'الصق الإعداد أدناه في إعدادات MCP في VS Code.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'الصق الإعداد أدناه في إعدادات MCP في Claude Code.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'يعمل مع أي عميل يقرأ إعداد mcpServers.',
|
||||||
|
'other_name' => 'أخرى',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'العلامة التجارية',
|
'brand' => 'العلامة التجارية',
|
||||||
'users' => 'الأعضاء',
|
'users' => 'الأعضاء',
|
||||||
'api_keys' => 'مفاتيح API',
|
'api_keys' => 'مفاتيح API',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'إعدادات مساحة العمل',
|
'title' => 'إعدادات مساحة العمل',
|
||||||
'logo_heading' => 'شعار مساحة العمل',
|
'logo_heading' => 'شعار مساحة العمل',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'التوقيعات',
|
'signatures' => 'التوقيعات',
|
||||||
'labels' => 'التسميات',
|
'labels' => 'التسميات',
|
||||||
'assets' => 'الوسائط',
|
'assets' => 'الوسائط',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'مفاتيح API',
|
'api_keys' => 'مفاتيح API',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/de/mcp.php
Normal file
47
lang/de/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Verbinde KI-Assistenten mit deinem TryPost-Workspace. Sie nutzen dieselben Berechtigungen wie jeder angemeldete Nutzer.',
|
||||||
|
'copy_step' => 'Kopiere deine TryPost-Server-URL',
|
||||||
|
'open_step' => 'Öffne deinen KI-Assistenten',
|
||||||
|
'copy' => 'URL kopieren',
|
||||||
|
'connect' => 'Mit :client verbinden',
|
||||||
|
'step_add' => 'Füge Name, URL oder Config unten in deine App ein. Die Anmeldung öffnet sich beim ersten Verbinden im Browser.',
|
||||||
|
'name_label' => 'Name',
|
||||||
|
'url_label' => 'Server-URL',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'Verbundene Apps',
|
||||||
|
'connected_description' => 'Assistenten, mit denen du dich angemeldet hast. Trenne Verbindungen, die du nicht mehr brauchst.',
|
||||||
|
'connected_empty' => 'Noch nichts verbunden. Nutze Claude, ChatGPT oder einen anderen Client oben.',
|
||||||
|
'disconnect' => 'Trennen',
|
||||||
|
'disconnect_title' => 'App trennen',
|
||||||
|
'disconnect_confirm' => 'Dadurch wird die App von TryPost abgemeldet. Sie muss sich neu verbinden, bevor sie MCP wieder nutzen kann.',
|
||||||
|
'disconnected' => 'App getrennt.',
|
||||||
|
'copied' => 'Kopiert',
|
||||||
|
'last_used' => 'Zuletzt verwendet',
|
||||||
|
'never' => 'Nie',
|
||||||
|
'documentation_title' => 'Dokumentation',
|
||||||
|
'documentation_description' => 'Einrichtungsguides pro Client, verfügbare Tools und Fehlerhilfe.',
|
||||||
|
'view_docs' => 'Dokumentation ansehen',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Andere Apps',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code und alles andere, das MCP spricht.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Öffne Settings → Connectors, füge einen benutzerdefinierten Connector hinzu und füge die URL oben ein.',
|
||||||
|
'chatgpt' => 'Öffne Settings → Apps & Connectors, erstelle einen benutzerdefinierten Connector und füge die URL oben ein.',
|
||||||
|
'cursor' => 'Füge TryPost in Cursor als Remote-MCP-Server hinzu.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Füge die Konfiguration unten in die MCP-Einstellungen von VS Code ein.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Füge die Konfiguration unten in die MCP-Einstellungen von Claude Code ein.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Funktioniert mit jedem Client, der eine mcpServers-Config liest.',
|
||||||
|
'other_name' => 'Andere',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -131,6 +131,7 @@
|
||||||
'brand' => 'Marke',
|
'brand' => 'Marke',
|
||||||
'users' => 'Mitglieder',
|
'users' => 'Mitglieder',
|
||||||
'api_keys' => 'API-Keys',
|
'api_keys' => 'API-Keys',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Workspace-Einstellungen',
|
'title' => 'Workspace-Einstellungen',
|
||||||
'logo_heading' => 'Workspace-Logo',
|
'logo_heading' => 'Workspace-Logo',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Signaturen',
|
'signatures' => 'Signaturen',
|
||||||
'labels' => 'Labels',
|
'labels' => 'Labels',
|
||||||
'assets' => 'Assets',
|
'assets' => 'Assets',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'API-Keys',
|
'api_keys' => 'API-Keys',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/el/mcp.php
Normal file
47
lang/el/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Συνδέστε βοηθούς AI για να δημιουργούν και να διαχειρίζονται αναρτήσεις με τον λογαριασμό TryPost σας.',
|
||||||
|
'copy_step' => 'Αντίγραψε το URL του διακομιστή TryPost',
|
||||||
|
'open_step' => 'Άνοιξε τον βοηθό AI σου',
|
||||||
|
'copy' => 'Αντιγραφή URL',
|
||||||
|
'connect' => 'Σύνδεση με :client',
|
||||||
|
'step_add' => 'Επικολλήστε το όνομα, το URL ή το config παρακάτω στην εφαρμογή σας. Η σύνδεση ανοίγει στο πρόγραμμα περιήγησης την πρώτη φορά.',
|
||||||
|
'name_label' => 'Όνομα',
|
||||||
|
'url_label' => 'URL διακομιστή',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'Συνδεδεμένες εφαρμογές',
|
||||||
|
'connected_description' => 'Βοηθοί στους οποίους έχετε συνδεθεί. Μπορείτε να αποσυνδέσετε όσους δεν χρησιμοποιείτε πλέον.',
|
||||||
|
'connected_empty' => 'Τίποτα συνδεδεμένο ακόμα. Χρησιμοποιήστε Claude, ChatGPT ή άλλο client παραπάνω.',
|
||||||
|
'disconnect' => 'Αποσύνδεση',
|
||||||
|
'disconnect_title' => 'Αποσύνδεση εφαρμογής',
|
||||||
|
'disconnect_confirm' => 'Αυτό αποσυνδέει την εφαρμογή από το TryPost. Θα χρειαστεί να συνδεθεί ξανά πριν χρησιμοποιήσει το MCP.',
|
||||||
|
'disconnected' => 'Η εφαρμογή αποσυνδέθηκε.',
|
||||||
|
'copied' => 'Αντιγράφηκε',
|
||||||
|
'last_used' => 'Τελευταία χρήση',
|
||||||
|
'never' => 'Ποτέ',
|
||||||
|
'documentation_title' => 'Τεκμηρίωση',
|
||||||
|
'documentation_description' => 'Οδηγοί ανά client, διαθέσιμα tools και αντιμετώπιση προβλημάτων.',
|
||||||
|
'view_docs' => 'Δείτε την τεκμηρίωση',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Άλλες εφαρμογές',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code και ό,τι άλλο μιλάει MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Άνοιξε Settings → Connectors, πρόσθεσε έναν προσαρμοσμένο connector και επικόλλησε το παραπάνω URL.',
|
||||||
|
'chatgpt' => 'Άνοιξε Settings → Apps & Connectors, δημιούργησε έναν προσαρμοσμένο connector και επικόλλησε το παραπάνω URL.',
|
||||||
|
'cursor' => 'Προσθέστε το TryPost ως απομακρυσμένο MCP server στο Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Επικολλήστε το config παρακάτω στις ρυθμίσεις MCP του VS Code.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Επικολλήστε το config παρακάτω στις ρυθμίσεις MCP του Claude Code.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Λειτουργεί με κάθε client που διαβάζει config mcpServers.',
|
||||||
|
'other_name' => 'Άλλα',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'Μάρκα',
|
'brand' => 'Μάρκα',
|
||||||
'users' => 'Μέλη',
|
'users' => 'Μέλη',
|
||||||
'api_keys' => 'Κλειδιά API',
|
'api_keys' => 'Κλειδιά API',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Ρυθμίσεις workspace',
|
'title' => 'Ρυθμίσεις workspace',
|
||||||
'logo_heading' => 'Λογότυπο workspace',
|
'logo_heading' => 'Λογότυπο workspace',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Υπογραφές',
|
'signatures' => 'Υπογραφές',
|
||||||
'labels' => 'Ετικέτες',
|
'labels' => 'Ετικέτες',
|
||||||
'assets' => 'Στοιχεία',
|
'assets' => 'Στοιχεία',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'Κλειδιά API',
|
'api_keys' => 'Κλειδιά API',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/en/mcp.php
Normal file
47
lang/en/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Connect AI assistants to your TryPost workspace. They use the same permissions as each signed-in user.',
|
||||||
|
'copy_step' => 'Copy your TryPost server URL',
|
||||||
|
'open_step' => 'Open your AI assistant',
|
||||||
|
'copy' => 'Copy URL',
|
||||||
|
'connect' => 'Connect with :client',
|
||||||
|
'step_add' => 'Paste the name, URL, or config below into your app. Sign-in opens in the browser the first time it connects.',
|
||||||
|
'name_label' => 'Name',
|
||||||
|
'url_label' => 'Server URL',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'Connected apps',
|
||||||
|
'connected_description' => 'Assistants you\'ve signed in with. You can disconnect any you no longer use.',
|
||||||
|
'connected_empty' => 'Nothing connected yet. Use Claude, ChatGPT, or another client above.',
|
||||||
|
'disconnect' => 'Disconnect',
|
||||||
|
'disconnect_title' => 'Disconnect app',
|
||||||
|
'disconnect_confirm' => 'This signs the app out of TryPost. It will need to reconnect before it can use MCP again.',
|
||||||
|
'disconnected' => 'App disconnected.',
|
||||||
|
'copied' => 'Copied',
|
||||||
|
'last_used' => 'Last used',
|
||||||
|
'never' => 'Never',
|
||||||
|
'documentation_title' => 'Documentation',
|
||||||
|
'documentation_description' => 'Client setup guides, available tools, and troubleshooting.',
|
||||||
|
'view_docs' => 'View docs',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Other apps',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code, and anything else that speaks MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Open Settings → Connectors, add a custom connector, then paste the URL above.',
|
||||||
|
'chatgpt' => 'Open Settings → Apps & Connectors, create a custom connector, then paste the URL above.',
|
||||||
|
'cursor' => 'Add TryPost as a remote MCP server in Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Paste the config below into VS Code\'s MCP settings.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Paste the config below into Claude Code\'s MCP settings.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Works with any client that reads an mcpServers config.',
|
||||||
|
'other_name' => 'Other',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'Brand',
|
'brand' => 'Brand',
|
||||||
'users' => 'Members',
|
'users' => 'Members',
|
||||||
'api_keys' => 'API Keys',
|
'api_keys' => 'API Keys',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Workspace settings',
|
'title' => 'Workspace settings',
|
||||||
'logo_heading' => 'Workspace logo',
|
'logo_heading' => 'Workspace logo',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Signatures',
|
'signatures' => 'Signatures',
|
||||||
'labels' => 'Labels',
|
'labels' => 'Labels',
|
||||||
'assets' => 'Assets',
|
'assets' => 'Assets',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'API Keys',
|
'api_keys' => 'API Keys',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/es/mcp.php
Normal file
47
lang/es/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Conecta asistentes de IA a tu workspace de TryPost. Usan los mismos permisos que cada usuario conectado.',
|
||||||
|
'copy_step' => 'Copia la URL del servidor TryPost',
|
||||||
|
'open_step' => 'Abre tu asistente de IA',
|
||||||
|
'copy' => 'Copiar URL',
|
||||||
|
'connect' => 'Conectar con :client',
|
||||||
|
'step_add' => 'Pega el nombre, la URL o la config abajo en tu app. El inicio de sesión se abre en el navegador la primera vez.',
|
||||||
|
'name_label' => 'Nombre',
|
||||||
|
'url_label' => 'URL del servidor',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'Apps conectadas',
|
||||||
|
'connected_description' => 'Asistentes con los que has iniciado sesión. Puedes desconectar los que ya no uses.',
|
||||||
|
'connected_empty' => 'Nada conectado aún. Usa Claude, ChatGPT u otro cliente arriba.',
|
||||||
|
'disconnect' => 'Desconectar',
|
||||||
|
'disconnect_title' => 'Desconectar app',
|
||||||
|
'disconnect_confirm' => 'Esto cierra la sesión de la app en TryPost. Tendrá que reconectar para usar MCP otra vez.',
|
||||||
|
'disconnected' => 'App desconectada.',
|
||||||
|
'copied' => 'Copiado',
|
||||||
|
'last_used' => 'Último uso',
|
||||||
|
'never' => 'Nunca',
|
||||||
|
'documentation_title' => 'Documentación',
|
||||||
|
'documentation_description' => 'Guías por cliente, tools disponibles y solución de problemas.',
|
||||||
|
'view_docs' => 'Ver documentación',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Otras apps',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code y cualquier app que hable MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Abre Settings → Connectors, añade un conector personalizado y pega la URL de arriba.',
|
||||||
|
'chatgpt' => 'Abre Settings → Apps & Connectors, crea un conector personalizado y pega la URL de arriba.',
|
||||||
|
'cursor' => 'Añade TryPost como servidor MCP remoto en Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Pega la configuración de abajo en los ajustes MCP de VS Code.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Pega la configuración de abajo en los ajustes MCP de Claude Code.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Funciona con cualquier cliente que lea una config mcpServers.',
|
||||||
|
'other_name' => 'Otros',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'Marca',
|
'brand' => 'Marca',
|
||||||
'users' => 'Miembros',
|
'users' => 'Miembros',
|
||||||
'api_keys' => 'API Keys',
|
'api_keys' => 'API Keys',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Configuración del workspace',
|
'title' => 'Configuración del workspace',
|
||||||
'logo_heading' => 'Logo del workspace',
|
'logo_heading' => 'Logo del workspace',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Firmas',
|
'signatures' => 'Firmas',
|
||||||
'labels' => 'Etiquetas',
|
'labels' => 'Etiquetas',
|
||||||
'assets' => 'Medios',
|
'assets' => 'Medios',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'API Keys',
|
'api_keys' => 'API Keys',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/fr/mcp.php
Normal file
47
lang/fr/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Connectez des assistants IA à votre workspace TryPost. Ils utilisent les mêmes permissions que chaque utilisateur connecté.',
|
||||||
|
'copy_step' => 'Copiez l’URL du serveur TryPost',
|
||||||
|
'open_step' => 'Ouvrez votre assistant IA',
|
||||||
|
'copy' => 'Copier l’URL',
|
||||||
|
'connect' => 'Connecter avec :client',
|
||||||
|
'step_add' => 'Collez le nom, l’URL ou la config ci-dessous dans votre app. La connexion s’ouvre dans le navigateur la première fois.',
|
||||||
|
'name_label' => 'Nom',
|
||||||
|
'url_label' => 'URL du serveur',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'Apps connectées',
|
||||||
|
'connected_description' => 'Assistants auxquels vous vous êtes connecté. Déconnectez ceux dont vous n’avez plus besoin.',
|
||||||
|
'connected_empty' => 'Rien de connecté pour l’instant. Utilisez Claude, ChatGPT ou un autre client ci-dessus.',
|
||||||
|
'disconnect' => 'Déconnecter',
|
||||||
|
'disconnect_title' => 'Déconnecter l’app',
|
||||||
|
'disconnect_confirm' => 'Cela déconnecte l’app de TryPost. Elle devra se reconnecter pour utiliser MCP à nouveau.',
|
||||||
|
'disconnected' => 'App déconnectée.',
|
||||||
|
'copied' => 'Copié',
|
||||||
|
'last_used' => 'Dernière utilisation',
|
||||||
|
'never' => 'Jamais',
|
||||||
|
'documentation_title' => 'Documentation',
|
||||||
|
'documentation_description' => 'Guides par client, tools disponibles et dépannage.',
|
||||||
|
'view_docs' => 'Voir la documentation',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Autres apps',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code et toute app qui parle MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Ouvrez Settings → Connectors, ajoutez un connecteur personnalisé, puis collez l’URL ci-dessus.',
|
||||||
|
'chatgpt' => 'Ouvrez Settings → Apps & Connectors, créez un connecteur personnalisé, puis collez l’URL ci-dessus.',
|
||||||
|
'cursor' => 'Ajoutez TryPost comme serveur MCP distant dans Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Collez la configuration ci-dessous dans les paramètres MCP de VS Code.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Collez la configuration ci-dessous dans les paramètres MCP de Claude Code.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Fonctionne avec tout client qui lit une config mcpServers.',
|
||||||
|
'other_name' => 'Autres',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'Marque',
|
'brand' => 'Marque',
|
||||||
'users' => 'Membres',
|
'users' => 'Membres',
|
||||||
'api_keys' => 'Clés API',
|
'api_keys' => 'Clés API',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Paramètres de l\'espace de travail',
|
'title' => 'Paramètres de l\'espace de travail',
|
||||||
'logo_heading' => 'Logo de l\'espace de travail',
|
'logo_heading' => 'Logo de l\'espace de travail',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Signatures',
|
'signatures' => 'Signatures',
|
||||||
'labels' => 'Étiquettes',
|
'labels' => 'Étiquettes',
|
||||||
'assets' => 'Médias',
|
'assets' => 'Médias',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'Clés API',
|
'api_keys' => 'Clés API',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/it/mcp.php
Normal file
47
lang/it/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'Collega assistenti IA al tuo workspace TryPost. Usano le stesse autorizzazioni di ogni utente connesso.',
|
||||||
|
'copy_step' => 'Copia l’URL del server TryPost',
|
||||||
|
'open_step' => 'Apri il tuo assistente IA',
|
||||||
|
'copy' => 'Copia URL',
|
||||||
|
'connect' => 'Collega con :client',
|
||||||
|
'step_add' => 'Incolla nome, URL o config qui sotto nella tua app. Il login si apre nel browser al primo collegamento.',
|
||||||
|
'name_label' => 'Nome',
|
||||||
|
'url_label' => 'URL del server',
|
||||||
|
'config_label' => 'Config',
|
||||||
|
'connected_title' => 'App collegate',
|
||||||
|
'connected_description' => 'Assistenti a cui hai effettuato l’accesso. Puoi disconnettere quelli che non usi più.',
|
||||||
|
'connected_empty' => 'Nessuna connessione ancora. Usa Claude, ChatGPT o un altro client sopra.',
|
||||||
|
'disconnect' => 'Scollega',
|
||||||
|
'disconnect_title' => 'Scollega app',
|
||||||
|
'disconnect_confirm' => 'Questo scollega l’app da TryPost. Dovrà riconnettersi prima di usare di nuovo MCP.',
|
||||||
|
'disconnected' => 'App scollegata.',
|
||||||
|
'copied' => 'Copiato',
|
||||||
|
'last_used' => 'Ultimo uso',
|
||||||
|
'never' => 'Mai',
|
||||||
|
'documentation_title' => 'Documentazione',
|
||||||
|
'documentation_description' => 'Guide per client, tools disponibili e risoluzione problemi.',
|
||||||
|
'view_docs' => 'Vedi documentazione',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'Altre app',
|
||||||
|
'other_clients_description' => 'Cursor, VS Code, Claude Code e qualsiasi app che parla MCP.',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Apri Settings → Connectors, aggiungi un connettore personalizzato e incolla l’URL qui sopra.',
|
||||||
|
'chatgpt' => 'Apri Settings → Apps & Connectors, crea un connettore personalizzato e incolla l’URL qui sopra.',
|
||||||
|
'cursor' => 'Aggiungi TryPost come server MCP remoto in Cursor.',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => 'Incolla la config qui sotto nelle impostazioni MCP di VS Code.',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => 'Incolla la config qui sotto nelle impostazioni MCP di Claude Code.',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'Funziona con qualsiasi client che legge una config mcpServers.',
|
||||||
|
'other_name' => 'Altri',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -129,6 +129,7 @@
|
||||||
'brand' => 'Brand',
|
'brand' => 'Brand',
|
||||||
'users' => 'Membri',
|
'users' => 'Membri',
|
||||||
'api_keys' => 'Chiavi API',
|
'api_keys' => 'Chiavi API',
|
||||||
|
'mcp' => 'MCP',
|
||||||
],
|
],
|
||||||
'title' => 'Impostazioni del workspace',
|
'title' => 'Impostazioni del workspace',
|
||||||
'logo_heading' => 'Logo del workspace',
|
'logo_heading' => 'Logo del workspace',
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@
|
||||||
'signatures' => 'Firme',
|
'signatures' => 'Firme',
|
||||||
'labels' => 'Etichette',
|
'labels' => 'Etichette',
|
||||||
'assets' => 'Risorse',
|
'assets' => 'Risorse',
|
||||||
|
'mcp' => 'MCP',
|
||||||
'api_keys' => 'Chiavi API',
|
'api_keys' => 'Chiavi API',
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|
|
||||||
47
lang/ja/mcp.php
Normal file
47
lang/ja/mcp.php
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'title' => 'MCP',
|
||||||
|
'subtitle' => 'TryPostアカウントで投稿の作成・管理ができるよう、AIアシスタントを接続します。',
|
||||||
|
'copy_step' => 'TryPostサーバーURLをコピー',
|
||||||
|
'open_step' => 'AIアシスタントを開く',
|
||||||
|
'copy' => 'URLをコピー',
|
||||||
|
'connect' => ':clientで接続',
|
||||||
|
'step_add' => '下の名前・URL・設定をアプリに貼り付けてください。初回接続時はブラウザでログインが開きます。',
|
||||||
|
'name_label' => '名前',
|
||||||
|
'url_label' => 'サーバーURL',
|
||||||
|
'config_label' => '設定',
|
||||||
|
'connected_title' => '接続済みアプリ',
|
||||||
|
'connected_description' => 'サインインしたアシスタントです。不要な接続は切断できます。',
|
||||||
|
'connected_empty' => 'まだ接続がありません。上の Claude、ChatGPT、または他のクライアントを使ってください。',
|
||||||
|
'disconnect' => '切断',
|
||||||
|
'disconnect_title' => 'アプリを切断',
|
||||||
|
'disconnect_confirm' => 'TryPostからアプリを切断します。再度MCPを使うには再接続が必要です。',
|
||||||
|
'disconnected' => 'アプリを切断しました。',
|
||||||
|
'copied' => 'コピーしました',
|
||||||
|
'last_used' => '最終使用',
|
||||||
|
'never' => 'なし',
|
||||||
|
'documentation_title' => 'ドキュメント',
|
||||||
|
'documentation_description' => 'クライアント別のセットアップ、利用可能なツール、トラブルシューティング。',
|
||||||
|
'view_docs' => 'ドキュメントを見る',
|
||||||
|
'connector_name' => 'TryPost',
|
||||||
|
'authorize_logged_in_as' => 'Logged in as:',
|
||||||
|
|
||||||
|
'other_clients_title' => 'その他のアプリ',
|
||||||
|
'other_clients_description' => 'Cursor、VS Code、Claude Code、その他MCP対応アプリ。',
|
||||||
|
|
||||||
|
'clients' => [
|
||||||
|
'claude' => 'Settings → Connectors を開き、カスタムコネクタを追加して上のURLを貼り付けます。',
|
||||||
|
'chatgpt' => 'Settings → Apps & Connectors を開き、カスタムコネクタを作成して上のURLを貼り付けます。',
|
||||||
|
'cursor' => 'CursorでTryPostをリモートMCPサーバーとして追加します。',
|
||||||
|
'cursor_name' => 'Cursor',
|
||||||
|
'vscode' => '下の設定をVS CodeのMCP設定に貼り付けます。',
|
||||||
|
'vscode_name' => 'VS Code',
|
||||||
|
'claude_code' => '下の設定をClaude CodeのMCP設定に貼り付けます。',
|
||||||
|
'claude_code_name' => 'Claude Code',
|
||||||
|
'other' => 'mcpServers設定を読むクライアントならどれでも使えます。',
|
||||||
|
'other_name' => 'その他',
|
||||||
|
],
|
||||||
|
];
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue