feat: implement MCP server with tools, Post API tests, auth middleware

- Create TryPostServer MCP server with 17 tools:
  Post (List, Get, Create, Delete), Hashtag (List, Create, Update, Delete),
  Label (List, Create, Update, Delete), Workspace (Get),
  ApiKey (List, Create, Delete)
- Create AuthenticateMcpToken middleware (logs in workspace owner)
- Register mcp.auth middleware alias in bootstrap/app.php
- Create routes/ai.php with mcp.trypost.test subdomain
- Add PostApiTest with 6 tests (list, show, create, delete, isolation)
- Fix PostApiTest assertions for pagination/resource wrapping
- 704 tests passing, frontend build passing
This commit is contained in:
Paulo Castellano 2026-03-29 20:30:36 -03:00
parent 039ed02b42
commit fabce14aad
42 changed files with 1962 additions and 201 deletions

View file

@ -33,9 +33,9 @@ ## Basic Usage
### Installation
```bash
php artisan vendor:publish --tag="cashier-migrations"
php artisan migrate
php artisan vendor:publish --tag="cashier-config"
vendor/bin/sail artisan vendor:publish --tag="cashier-migrations"
vendor/bin/sail artisan migrate
vendor/bin/sail artisan vendor:publish --tag="cashier-config"
```
### Environment Variables

View file

@ -24,7 +24,7 @@ ## Basic Usage
### Installation
```bash
php artisan horizon:install
vendor/bin/sail artisan horizon:install
```
### Supervisor Configuration
@ -70,7 +70,7 @@ ### Dashboard Authorization
## Verification
1. Run `php artisan horizon` and visit `/horizon`
1. Run `vendor/bin/sail artisan horizon` and visit `/horizon`
2. Confirm dashboard access is restricted as expected
3. Check that metrics populate after scheduling `horizon:snapshot`
@ -81,5 +81,5 @@ ## Common Pitfalls
- Always check `config/horizon.php` before making changes to understand the current supervisor and environment configuration.
- The `environments` array overrides only the keys you specify. It merges into `defaults` and does not replace it.
- The timeout chain must be ordered: job `timeout` less than supervisor `timeout` less than `retry_after`. The wrong order can cause jobs to be retried before Horizon finishes timing them out.
- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `php artisan horizon` alone does not populate metrics.
- The metrics dashboard stays blank until `horizon:snapshot` is scheduled. Running `vendor/bin/sail artisan horizon` alone does not populate metrics.
- Always use `search-docs` for the latest Horizon documentation rather than relying on this skill alone.

View file

@ -1,6 +1,6 @@
---
name: inertia-vue-development
description: "Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
description: "Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation."
license: MIT
metadata:
author: laravel
@ -8,9 +8,19 @@
# Inertia Vue Development
## When to Apply
Activate this skill when:
- Creating or modifying Vue page components for Inertia
- Working with forms in Vue (using `<Form>`, `useForm`, or `useHttp`)
- Implementing client-side navigation with `<Link>` or `router`
- Using v3 features: deferred props, prefetching, optimistic updates, instant visits, layout props, HTTP requests, WhenVisible, InfiniteScroll, once props, flash data, or polling
- Building Vue-specific features with the Inertia protocol
## Documentation
Use `search-docs` for detailed Inertia v2 Vue patterns and documentation.
Use `search-docs` for detailed Inertia v3 Vue patterns and documentation.
## Basic Usage
@ -20,8 +30,6 @@ ### Page Components Location
### Page Component Structure
Important: Vue components must have a single root element.
<!-- Basic Vue Page Component -->
```vue
<script setup>
@ -271,7 +279,137 @@ ### `useForm` Composable
</template>
```
## Inertia v2 Features
## Inertia v3 Features
### HTTP Requests
Use the `useHttp` hook for standalone HTTP requests that do not trigger Inertia page visits. It provides the same developer experience as `useForm`, but for plain JSON endpoints.
<!-- useHttp Example -->
```vue
<script setup>
import { useHttp } from '@inertiajs/vue3'
const http = useHttp({
query: '',
})
function search() {
http.get('/api/search', {
onSuccess: (response) => {
console.log(response)
},
})
}
</script>
<template>
<input v-model="http.query" @input="search" />
<div v-if="http.processing">Searching...</div>
</template>
```
### Optimistic Updates
Apply data changes instantly before the server responds, with automatic rollback on failure:
<!-- Optimistic Update with Router -->
```vue
<script setup>
import { router } from '@inertiajs/vue3'
function like(post) {
router.optimistic((props) => ({
post: {
...props.post,
likes: props.post.likes + 1,
},
})).post(`/posts/${post.id}/like`)
}
</script>
```
Optimistic updates also work with `useForm` and the `<Form>` component:
<!-- Optimistic Update with Form Component -->
```vue
<template>
<Form
action="/todos"
method="post"
:optimistic="(props, data) => ({
todos: [...props.todos, { id: Date.now(), name: data.name, done: false }],
})"
>
<input type="text" name="name" />
<button type="submit">Add Todo</button>
</Form>
</template>
```
### Instant Visits
Navigate to a new page immediately without waiting for the server response. The target component renders right away with shared props, while page-specific props load in the background.
<!-- Instant Visit with Link -->
```vue
<script setup>
import { Link } from '@inertiajs/vue3'
</script>
<template>
<Link href="/dashboard" component="Dashboard">Dashboard</Link>
<Link
href="/posts/1"
component="Posts/Show"
:page-props="{ post: { id: 1, title: 'My Post' } }"
>
View Post
</Link>
</template>
```
### Layout Props
Share dynamic data between pages and persistent layouts:
<!-- Layout Props in Layout -->
```vue
<script setup>
withDefaults(defineProps({
title: String,
showSidebar: Boolean,
}), {
title: 'My App',
showSidebar: true,
})
</script>
<template>
<header>{{ title }}</header>
<aside v-if="showSidebar">Sidebar</aside>
<main>
<slot />
</main>
</template>
```
<!-- Setting Layout Props from Page -->
```vue
<script setup>
import { setLayoutProps } from '@inertiajs/vue3'
setLayoutProps({
title: 'Dashboard',
showSidebar: false,
})
</script>
<template>
<h1>Dashboard</h1>
</template>
```
### Deferred Props
@ -358,42 +496,69 @@ ### Polling
</template>
```
- `autoStart` (default `true`) set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) set to `true` to prevent throttling when the browser tab is inactive
- `autoStart` (default `true`) - set to `false` to start polling manually via the returned `start()` function
- `keepAlive` (default `false`) - set to `true` to prevent throttling when the browser tab is inactive
### WhenVisible (Infinite Scroll)
### WhenVisible
Load more data when user scrolls to a specific element:
Lazy-load a prop when an element scrolls into view. Useful for deferring expensive data that sits below the fold:
<!-- Infinite Scroll with WhenVisible -->
<!-- WhenVisible Example -->
```vue
<script setup>
import { WhenVisible } from '@inertiajs/vue3'
defineProps({
stats: Object
})
</script>
<template>
<div>
<h1>Dashboard</h1>
<WhenVisible data="stats" :buffer="200">
<template #fallback>
<div class="animate-pulse">Loading stats...</div>
</template>
<template #default="{ fetching }">
<div>
<p>Total Users: {{ stats.total_users }}</p>
<p>Revenue: {{ stats.revenue }}</p>
<span v-if="fetching">Refreshing...</span>
</div>
</template>
</WhenVisible>
</div>
</template>
```
### InfiniteScroll
Automatically load additional pages of paginated data as users scroll:
<!-- InfiniteScroll Example -->
```vue
<script setup>
import { InfiniteScroll } from '@inertiajs/vue3'
defineProps({
users: Object
})
</script>
<template>
<div>
<InfiniteScroll data="users">
<div v-for="user in users.data" :key="user.id">
{{ user.name }}
</div>
<WhenVisible
v-if="users.next_page_url"
data="users"
:params="{ page: users.current_page + 1 }"
>
<template #fallback>
<div>Loading more...</div>
</template>
</WhenVisible>
</div>
</InfiniteScroll>
</template>
```
The server must use `Inertia::scroll()` to configure the paginated data. Use the `search-docs` tool with a query of `infinite scroll` for detailed guidance on buffers, manual loading, reverse mode, and custom trigger elements.
## Server-Side Patterns
Server-side patterns (Inertia::render, props, middleware) are covered in inertia-laravel guidelines.
@ -405,4 +570,6 @@ ## Common Pitfalls
- Forgetting to add loading states (skeleton screens) when using deferred props
- Not handling the `undefined` state of deferred props before data loads
- Using `<form>` without preventing default submission (use `<Form>` component or `@submit.prevent`)
- Forgetting to check if `<Form>` component is available in your Inertia version
- Forgetting to check if `<Form>` component is available in your Inertia version
- Using `router.cancel()` instead of `router.cancelAll()` (v3 breaking change)
- Using `router.on('invalid', ...)` or `router.on('exception', ...)` instead of the renamed `httpException` and `networkError` events

View file

@ -8,148 +8,88 @@
# MCP Development
## Documentation First
## Documentation
**CRITICAL**: Always use `search-docs` BEFORE writing MCP code. The documentation is version-specific, comprehensive, and always up-to-date.
Use `search-docs` for detailed Laravel MCP patterns and documentation.
<!-- Search MCP Documentation -->
```bash
## Basic Usage
# Example searches
Register MCP servers in `routes/ai.php`:
search-docs(['mcp tools', 'mcp resources', 'mcp validation'])
```
## Quick Reference
### Artisan Commands
Create MCP Primitives"
```bash
php artisan make:mcp-tool ToolName
php artisan make:mcp-resource ResourceName
php artisan make:mcp-prompt PromptName
php artisan make:mcp-server ServerName
```
### Basic Tool Implementation
<!-- Tool Example -->
<!-- Register MCP Server -->
```php
use Laravel\Mcp\Facades\Mcp;
Mcp::web();
```
### Creating MCP Primitives
Create MCP tools, resources, prompts, and servers using artisan commands:
```bash
vendor/bin/sail artisan make:mcp-tool ToolName # Create a tool
vendor/bin/sail artisan make:mcp-resource ResourceName # Create a resource
vendor/bin/sail artisan make:mcp-prompt PromptName # Create a prompt
vendor/bin/sail artisan make:mcp-server ServerName # Create a server
```
After creating primitives, register them in your server's `$tools`, `$resources`, or `$prompts` properties.
### Tools
<!-- MCP Tool Example -->
```php
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Request;
use Laravel\Mcp\Server\Response;
class MyTool extends Tool
{
protected string $description = 'Tool description for LLM';
public function schema(JsonSchema $schema): array
{
return [
'param' => $schema->string()->required(),
];
}
public function handle(Request $request): Response
{
return Response::text($request->get('param'));
return new Response(['result' => 'success']);
}
}
```
### Basic Resource Implementation
### Registering Primitives in a Server
<!-- Resource Example -->
Each MCP server must explicitly declare the tools, resources, and prompts it exposes.
<!-- Register Primitives in MCP Server -->
```php
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server;
class MyResource extends Resource
class AppServer extends Server
{
protected string $description = 'Resource description';
protected string $uri = 'file://path/to/resource';
protected string $mimeType = 'text/markdown';
protected array $tools = [
\App\Mcp\Tools\MyTool::class,
];
public function handle(): Response
{
return Response::text($content);
}
protected array $resources = [
\App\Mcp\Resources\MyResource::class,
];
protected array $prompts = [
\App\Mcp\Prompts\MyPrompt::class,
];
}
```
### Response Methods
## Verification
<!-- Available Responses -->
```php
Response::text('Text content');
Response::error('Error message');
Response::structured(['key' => 'value']);
```
## Testing MCP Primitives
Test tools, resources, and prompts directly on their server:
<!-- Test MCP Primitives -->
```php
// Test a tool
$response = MyServer::tool(MyTool::class, ['param' => 'value']);
$response->assertOk()->assertSee('Expected text');
// Test as authenticated user
$response = MyServer::actingAs($user)->tool(MyTool::class, [...]);
// Available assertions
$response->assertOk();
$response->assertSee('text');
$response->assertHasErrors();
$response->assertHasNoErrors();
$response->assertName('tool-name');
$response->assertSentNotification('event/type', ['data' => 'value']);
```
### MCP Inspector
Test interactively using the inspector:
<!--Launch MCP Inspector-->
```bash
php artisan mcp:inspector mcp/my-server # Web server
php artisan mcp:inspector my-server # Local server
```
## Available Features
The following features exist—**use `search-docs` for implementation details**:
- **Tools**: `schema()`, validation, annotations (`#[IsReadOnly]`, `#[IsDestructive]`, etc.)
- **Resources**: URI templates (`HasUriTemplate`), Dynamic resources
- **Prompts**: Arguments, multi-message responses
- **All primitives**: Dependency injection, `shouldRegister()`, validation
- **Responses**: Text, error, structured, streaming, metadata
- **Server registration**: Web routes, local routes, OAuth
## Critical Imports
<!-- Correct Imports -->
```php
use Laravel\Mcp\Request; // NOT Laravel\Mcp\Server\Request
use Laravel\Mcp\Response; // NOT Laravel\Mcp\Server\Response
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Resource;
use Laravel\Mcp\Server\Prompt;
use Illuminate\Contracts\JsonSchema\JsonSchema;
```
1. Check `routes/ai.php` for proper registration
2. Test tool via MCP client
## Common Pitfalls
- **Not using `search-docs` before implementation**
- Wrong imports: `Laravel\Mcp\Server\Request` (wrong) vs `Laravel\Mcp\Request` (correct)
- Forgetting `schema()` method for tools with parameters
- Missing required properties: `$description`, `$uri`, `$mimeType`
- Wrong response pattern: `new Response()` instead of `Response::text()`
- Running `mcp:start` command locally (hangs waiting for stdin)
- Running `mcp:start` command (it hangs waiting for input)
- Using HTTPS locally with Node-based MCP clients
- Not using `search-docs` for the latest MCP documentation
- Not registering MCP server routes in `routes/ai.php`
- Do not register `ai.php` in `bootstrap.php`; it is registered automatically.

View file

@ -16,7 +16,7 @@ ## Basic Usage
### Creating Tests
All tests must be written using Pest. Use `php artisan make:test --pest {name}`.
All tests must be written using Pest. Use `vendor/bin/sail artisan make:test --pest {name}`.
### Test Organization
@ -35,9 +35,9 @@ ### Basic Test Structure
### Running Tests
- Run minimal tests with filter before finalizing: `php artisan test --compact --filter=testName`.
- Run all tests: `php artisan test --compact`.
- Run file: `php artisan test --compact tests/Feature/ExampleTest.php`.
- Run minimal tests with filter before finalizing: `vendor/bin/sail artisan test --compact --filter=testName`.
- Run all tests: `vendor/bin/sail artisan test --compact`.
- Run file: `vendor/bin/sail artisan test --compact tests/Feature/ExampleTest.php`.
## Assertions

View file

@ -18,11 +18,11 @@ ### Generate Routes
Run after route changes if Vite plugin isn't installed:
```bash
php artisan wayfinder:generate --no-interaction
vendor/bin/sail artisan wayfinder:generate --no-interaction
```
For form helpers, use `--with-form` flag:
```bash
php artisan wayfinder:generate --with-form --no-interaction
vendor/bin/sail artisan wayfinder:generate --with-form --no-interaction
```
### Import Patterns
@ -69,7 +69,7 @@ ## Wayfinder + Inertia
## Verification
1. Run `php artisan wayfinder:generate` to regenerate routes if Vite plugin isn't installed
1. Run `vendor/bin/sail artisan wayfinder:generate` to regenerate routes if Vite plugin isn't installed
2. Check TypeScript imports resolve correctly
3. Verify route URLs match expected paths

View file

@ -12,24 +12,23 @@ ## Foundational Context
- php - 8.4
- inertiajs/inertia-laravel (INERTIA_LARAVEL) - v3
- laravel/ai (AI) - v0
- 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/pennant (PENNANT) - v1
- laravel/prompts (PROMPTS) - v0
- laravel/reverb (REVERB) - v1
- laravel/socialite (SOCIALITE) - v5
- laravel/wayfinder (WAYFINDER) - v0
- laravel/boost (BOOST) - v2
- laravel/pail (PAIL) - v1
- laravel/pint (PINT) - v1
- laravel/sail (SAIL) - v1
- laravel/telescope (TELESCOPE) - v5
- pestphp/pest (PEST) - v4
- phpunit/phpunit (PHPUNIT) - v12
- @inertiajs/vue3 (INERTIA_VUE) - v2
- @inertiajs/vue3 (INERTIA_VUE) - v3
- tailwindcss (TAILWINDCSS) - v4
- vue (VUE) - v3
- @laravel/echo-vue (ECHO_VUE) - v2
@ -45,15 +44,12 @@ ## Skills Activation
- `cashier-stripe-development` — Handles Laravel Cashier Stripe integration including subscriptions, webhooks, Stripe Checkout, invoices, charges, refunds, trials, coupons, metered billing, and payment failure handling. Triggered when a user mentions Cashier, Billable, IncompletePayment, stripe_id, newSubscription, Stripe subscriptions, or billing. Also applies when setting up webhooks, handling SCA/3DS payment failures, testing with Stripe test cards, or troubleshooting incomplete subscriptions, CSRF webhook errors, or migration publish issues.
- `laravel-best-practices` — Apply this skill whenever writing, reviewing, or refactoring Laravel PHP code. This includes creating or modifying controllers, models, migrations, form requests, policies, jobs, scheduled commands, service classes, and Eloquent queries. Triggers for N+1 and query performance issues, caching strategies, authorization and security patterns, validation, error handling, queue and job configuration, route definitions, and architectural decisions. Also use for Laravel code reviews and refactoring existing Laravel code to follow best practices. Covers any task involving Laravel backend PHP code patterns.
- `configuring-horizon` — Use this skill whenever the user mentions Horizon by name in a Laravel context. Covers the full Horizon lifecycle: installing Horizon (horizon:install, Sail setup), configuring config/horizon.php (supervisor blocks, queue assignments, balancing strategies, minProcesses/maxProcesses), fixing the dashboard (authorization via Gate::define viewHorizon, blank metrics, horizon:snapshot scheduling), and troubleshooting production issues (worker crashes, timeout chain ordering, LongWaitDetected notifications, waits config). Also covers job tagging and silencing. Do not use for generic Laravel queues without Horizon, SQS or database drivers, standalone Redis setup, Linux supervisord, Telescope, or job batching.
- `mcp-development` — 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.
- `pennant-development` — Use when working with Laravel Pennant the official Laravel feature flag package. Trigger whenever the query mentions Pennant by name or involves feature flags or feature toggles in a Laravel project. Tasks include defining feature flags checking whether features are active creating class based features in `app/Features` using Blade `@feature` directives scoping flags to users or teams building custom Pennant storage drivers protecting routes with feature flags testing feature flags with Pest or PHPUnit and implementing A B testing or gradual rollouts with feature flags. Do not trigger for generic Laravel configuration authorization policies authentication or non Pennant feature management systems.
- `mcp-development` — 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.
- `socialite-development` — Manages OAuth social authentication with Laravel Socialite. Activate when adding social login providers; configuring OAuth redirect/callback flows; retrieving authenticated user details; customizing scopes or parameters; setting up community providers; testing with Socialite fakes; or when the user mentions social login, OAuth, Socialite, or third-party authentication.
- `wayfinder-development` — Use this skill for Laravel Wayfinder which auto-generates typed functions for Laravel controllers and routes. ALWAYS use this skill when frontend code needs to call backend routes or controller actions. Trigger when: connecting any React/Vue/Svelte/Inertia frontend to Laravel controllers, routes, building end-to-end features with both frontend and backend, wiring up forms or links to backend endpoints, fixing route-related TypeScript errors, importing from @/actions or @/routes, or running wayfinder:generate. Use Wayfinder route functions instead of hardcoded URLs. Covers: wayfinder() vite plugin, .url()/.get()/.post()/.form(), query params, route model binding, tree-shaking. Do not use for backend-only task
- `pest-testing` — Use this skill for Pest PHP testing in Laravel projects only. Trigger whenever any test is being written, edited, fixed, or refactored — including fixing tests that broke after a code change, adding assertions, converting PHPUnit to Pest, adding datasets, and TDD workflows. Always activate when the user asks how to write something in Pest, mentions test files or directories (tests/Feature, tests/Unit, tests/Browser), or needs browser testing, smoke testing multiple pages for JS errors, or architecture tests. Covers: it()/expect() syntax, datasets, mocking, browser testing (visit/click/fill), smoke testing, arch(), Livewire component tests, RefreshDatabase, and all Pest 4 features. Do not use for factories, seeders, migrations, controllers, models, or non-test PHP code.
- `inertia-vue-development` — Develops Inertia.js v2 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, or router; working with deferred props, prefetching, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `inertia-vue-development` — Develops Inertia.js v3 Vue client-side applications. Activates when creating Vue pages, forms, or navigation; using <Link>, <Form>, useForm, useHttp, setLayoutProps, or router; working with deferred props, prefetching, optimistic updates, instant visits, or polling; or when user mentions Vue with Inertia, Vue pages, Vue forms, or Vue navigation.
- `tailwindcss-development` — Always invoke when the user's message includes 'tailwind' in any form. Also invoke for: building responsive grid layouts (multi-column card grids, product grids), flex/grid page structures (dashboards with sidebars, fixed topbars, mobile-toggle navs), styling UI components (cards, tables, navbars, pricing sections, forms, inputs, badges), adding dark mode variants, fixing spacing or typography, and Tailwind v3/v4 work. The core use case: writing or fixing Tailwind utility classes in HTML templates (Blade, JSX, Vue). Skip for backend PHP logic, database queries, API routes, JavaScript with no HTML/CSS component, CSS file audits, build tool configuration, and vanilla CSS.
- `ai-sdk-development` — Builds AI agents, generates text and chat responses, produces images, synthesizes audio, transcribes speech, generates vector embeddings, reranks documents, and manages files and vector stores using the Laravel AI SDK (laravel/ai). Supports structured output, streaming, tools, conversation memory, middleware, queueing, broadcasting, and provider failover. Use when building, editing, updating, debugging, or testing any AI functionality, including agents, LLMs, chatbots, text generation, image generation, audio, transcription, embeddings, RAG, similarity search, vector stores, prompting, structured output, or any AI provider (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter).
- `medialibrary-development` — Build and work with spatie/laravel-medialibrary features including associating files with Eloquent models, defining media collections and conversions, generating responsive images, and retrieving media URLs and paths.
## Conventions
@ -72,7 +68,7 @@ ## Application Structure & Architecture
## Frontend Bundling
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `npm run build`, `npm run dev`, or `composer run dev`. Ask them.
- If the user doesn't see a frontend change reflected in the UI, it could mean they need to run `vendor/bin/sail npm run build`, `vendor/bin/sail npm run dev`, or `vendor/bin/sail composer run dev`. Ask them.
## Documentation Files
@ -110,22 +106,21 @@ ### Search Syntax
## 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.
- Inspect routes with `php artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `php artisan config:show app.name`, `php artisan config:show database.default`. Or read config files directly from the `config/` directory.
- Run Artisan commands directly via the command line (e.g., `vendor/bin/sail artisan route:list`). Use `vendor/bin/sail artisan list` to discover available commands and `vendor/bin/sail artisan [command] --help` to check parameters.
- Inspect routes with `vendor/bin/sail artisan route:list`. Filter with: `--method=GET`, `--name=users`, `--path=api`, `--except-vendor`, `--only-vendor`.
- Read configuration values using dot notation: `vendor/bin/sail artisan config:show app.name`, `vendor/bin/sail artisan config:show database.default`. Or read config files directly from the `config/` directory.
- To check environment variables, read the `.env` file directly.
## Tinker
- Execute PHP in app context for debugging and testing code. Do not create models without user approval, prefer tests with factories instead. Prefer existing Artisan commands over custom tinker code.
- Always use single quotes to prevent shell expansion: `php artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `php artisan tinker --execute 'User::where("active", true)->count();'`
- Always use single quotes to prevent shell expansion: `vendor/bin/sail artisan tinker --execute 'Your::code();'`
- Double quotes for PHP strings inside: `vendor/bin/sail artisan tinker --execute 'User::where("active", true)->count();'`
=== php rules ===
# PHP
- Always declare `declare(strict_types=1);` at the top of every `.php` file.
- Always use curly braces for control structures, even for single-line bodies.
- Use PHP 8 constructor property promotion: `public function __construct(public GitHub $github) { }`. Do not leave empty zero-parameter `__construct()` methods unless the constructor is private.
- Use explicit return type declarations and type hints for all method parameters: `function isAccessible(User $user, ?string $path = null): bool`
@ -133,19 +128,26 @@ # PHP
- Prefer PHPDoc blocks over inline comments. Only add inline comments for exceptionally complex logic.
- Use array shape type definitions in PHPDoc blocks.
=== herd rules ===
=== sail rules ===
# Laravel Herd
# Laravel Sail
- The application is served by Laravel Herd at `https?://[kebab-case-project-dir].test`. Use the `get-absolute-url` tool to generate valid URLs. Never run commands to serve the site. It is always available.
- Use the `herd` CLI to manage services, PHP versions, and sites (e.g. `herd sites`, `herd services:start <service>`, `herd php:list`). Run `herd list` to discover all available commands.
- This project runs inside Laravel Sail's Docker containers. You MUST execute all commands through Sail.
- Start services using `vendor/bin/sail up -d` and stop them with `vendor/bin/sail stop`.
- Open the application in the browser by running `vendor/bin/sail open`.
- Always prefix PHP, Artisan, Composer, and Node commands with `vendor/bin/sail`. Examples:
- Run Artisan Commands: `vendor/bin/sail artisan migrate`
- Install Composer packages: `vendor/bin/sail composer install`
- Execute Node commands: `vendor/bin/sail npm run dev`
- Execute PHP scripts: `vendor/bin/sail php [script]`
- View all available Sail commands by running `vendor/bin/sail` without arguments.
=== tests rules ===
# Test Enforcement
- Every change must be programmatically tested. Write a new test or update an existing test, then run the affected tests to make sure they pass.
- Run the minimum number of tests needed to ensure code quality and speed. Use `php artisan test --compact` with a specific filename or filter.
- Run the minimum number of tests needed to ensure code quality and speed. Use `vendor/bin/sail artisan test --compact` with a specific filename or filter.
=== inertia-laravel/core rules ===
@ -174,13 +176,13 @@ # Inertia v3
# Do Things the Laravel Way
- Use `php artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `php artisan list` and check their parameters with `php artisan [command] --help`.
- If you're creating a generic PHP class, use `php artisan make:class`.
- Use `vendor/bin/sail artisan make:` commands to create new files (i.e. migrations, controllers, models, etc.). You can list available Artisan commands using `vendor/bin/sail artisan list` and check their parameters with `vendor/bin/sail artisan [command] --help`.
- If you're creating a generic PHP class, use `vendor/bin/sail artisan make:class`.
- Pass `--no-interaction` to all Artisan commands to ensure they work without user input. You should also pass the correct `--options` to ensure correct behavior.
### Model Creation
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `php artisan make:model --help` to check the available options.
- When creating new models, create useful factories and seeders for them too. Ask the user if they need any other things, using `vendor/bin/sail artisan make:model --help` to check the available options.
## APIs & Eloquent Resources
@ -194,11 +196,11 @@ ## Testing
- When creating models for tests, use the factories for the models. Check if the factory has custom states that can be used before manually setting up the model.
- Faker: Use methods such as `$this->faker->word()` or `fake()->randomDigit()`. Follow existing conventions whether to use `$this->faker` or `fake()`.
- When creating tests, make use of `php artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
- When creating tests, make use of `vendor/bin/sail artisan make:test [options] {name}` to create a feature test, and pass `--unit` to create a unit test. Most tests should be feature tests.
## Vite Error
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `npm run build` or ask the user to run `npm run dev` or `composer run dev`.
- If you receive an "Illuminate\Foundation\ViteException: Unable to locate file in Vite manifest" error, you can run `vendor/bin/sail npm run build` or ask the user to run `vendor/bin/sail npm run dev` or `vendor/bin/sail composer run dev`.
=== wayfinder/core rules ===
@ -210,15 +212,15 @@ # Laravel Wayfinder
# Laravel Pint Code Formatter
- If you have modified any PHP files, you must run `vendor/bin/pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/pint --test --format agent`, simply run `vendor/bin/pint --format agent` to fix any formatting issues.
- If you have modified any PHP files, you must run `vendor/bin/sail bin pint --dirty --format agent` before finalizing changes to ensure your code matches the project's expected style.
- Do not run `vendor/bin/sail bin pint --test --format agent`, simply run `vendor/bin/sail bin pint --format agent` to fix any formatting issues.
=== pest/core rules ===
## Pest
- This project uses Pest for testing. Create tests: `php artisan make:test --pest {name}`.
- Run tests: `php artisan test --compact` or filter: `php artisan test --compact --filter=testName`.
- This project uses Pest for testing. Create tests: `vendor/bin/sail artisan make:test --pest {name}`.
- Run tests: `vendor/bin/sail artisan test --compact` or filter: `vendor/bin/sail artisan test --compact --filter=testName`.
- Do NOT delete tests without approval.
=== inertia-vue/core rules ===
@ -226,23 +228,8 @@ ## Pest
# Inertia + Vue
Vue components must have a single root element.
- IMPORTANT: Activate `inertia-vue-development` when working with Inertia Vue client-side patterns.
=== laravel/ai rules ===
## Laravel AI SDK
- This application uses the Laravel AI SDK (`laravel/ai`) for all AI functionality.
- Activate the `developing-with-ai-sdk` skill when building, editing, updating, debugging, or testing AI agents, text generation, chat, streaming, structured output, tools, image generation, audio, transcription, embeddings, reranking, vector stores, files, conversation memory, or any AI provider integration (OpenAI, Anthropic, Gemini, Cohere, Groq, xAI, ElevenLabs, Jina, OpenRouter).
=== spatie/laravel-medialibrary rules ===
## Media Library
- `spatie/laravel-medialibrary` associates files with Eloquent models, with support for collections, conversions, and responsive images.
- Always activate the `medialibrary-development` skill when working with media uploads, conversions, collections, responsive images, or any code that uses the `HasMedia` interface or `InteractsWithMedia` trait.
</laravel-boost-guidelines>
# Project-Specific Rules

View file

@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Http\Middleware\Mcp;
use App\Models\ApiToken;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Response;
class AuthenticateMcpToken
{
public function handle(Request $request, Closure $next): Response
{
$token = $request->bearerToken();
if (! $token) {
return response()->json(['message' => 'Missing API key.'], Response::HTTP_UNAUTHORIZED);
}
if (! str_starts_with($token, 'tp_') || strlen($token) !== 51) {
return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED);
}
$lookup = substr($token, 3, 16);
$apiToken = ApiToken::where('token_lookup', $lookup)->first();
if (! $apiToken || ! Hash::check($token, $apiToken->token_hash)) {
return response()->json(['message' => 'Invalid API key.'], Response::HTTP_UNAUTHORIZED);
}
if ($apiToken->status === 'expired') {
return response()->json(['message' => 'API key has expired.'], Response::HTTP_UNAUTHORIZED);
}
$apiToken->update(['last_used_at' => now()]);
$workspace = $apiToken->workspace;
$user = $workspace->owner;
if (! $user) {
return response()->json(['message' => 'No workspace owner found.'], Response::HTTP_UNAUTHORIZED);
}
$user->current_workspace_id = $workspace->id;
Auth::login($user);
return $next($request);
}
}

View file

@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Servers;
use App\Mcp\Tools\ApiKey\CreateApiKeyTool;
use App\Mcp\Tools\ApiKey\DeleteApiKeyTool;
use App\Mcp\Tools\ApiKey\ListApiKeysTool;
use App\Mcp\Tools\Hashtag\CreateHashtagTool;
use App\Mcp\Tools\Hashtag\DeleteHashtagTool;
use App\Mcp\Tools\Hashtag\ListHashtagsTool;
use App\Mcp\Tools\Hashtag\UpdateHashtagTool;
use App\Mcp\Tools\Label\CreateLabelTool;
use App\Mcp\Tools\Label\DeleteLabelTool;
use App\Mcp\Tools\Label\ListLabelsTool;
use App\Mcp\Tools\Label\UpdateLabelTool;
use App\Mcp\Tools\Post\CreatePostTool;
use App\Mcp\Tools\Post\DeletePostTool;
use App\Mcp\Tools\Post\GetPostTool;
use App\Mcp\Tools\Post\ListPostsTool;
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Attributes\Instructions;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Attributes\Version;
#[Name('TryPost')]
#[Version('1.0.0')]
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, hashtag groups, labels, workspaces, and API keys.')]
class TryPostServer extends Server
{
public int $defaultPaginationLength = 100;
protected array $tools = [
// Posts
ListPostsTool::class,
GetPostTool::class,
CreatePostTool::class,
DeletePostTool::class,
// Hashtags
ListHashtagsTool::class,
CreateHashtagTool::class,
UpdateHashtagTool::class,
DeleteHashtagTool::class,
// Labels
ListLabelsTool::class,
CreateLabelTool::class,
UpdateLabelTool::class,
DeleteLabelTool::class,
// Workspace
GetWorkspaceTool::class,
// API Keys
ListApiKeysTool::class,
CreateApiKeyTool::class,
DeleteApiKeyTool::class,
];
protected array $resources = [];
protected array $prompts = [];
}

View file

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\ApiKey;
use App\Actions\ApiKey\CreateApiKey;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new API key. Returns the plain text token which is only shown once.')]
class CreateApiKeyTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'expires_at' => ['nullable', 'date', 'after:now'],
]);
$result = CreateApiKey::execute($request->user()->currentWorkspace, $validated);
return Response::structured([
...$result['token']->toArray(),
'token' => $result['plain_token'],
]);
}
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->required()->description('The API key name.'),
'expires_at' => $schema->string()->description('Optional expiration date.'),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\ApiKey;
use App\Actions\ApiKey\DeleteApiKey;
use App\Models\ApiToken;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Delete an API key by ID.')]
class DeleteApiKeyTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$apiToken = ApiToken::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($request->validated(), 'api_key_id'));
DeleteApiKey::execute($apiToken);
return Response::structured(['deleted' => true]);
}
public function schema(JsonSchema $schema): array
{
return [
'api_key_id' => $schema->string()->required()->description('The API key ID to delete.'),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\ApiKey;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all API keys for the current workspace.')]
class ListApiKeysTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$tokens = $request->user()->currentWorkspace->apiTokens()->latest()->get();
return Response::structured($tokens->toArray());
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Hashtag;
use App\Actions\Hashtag\CreateHashtag;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new hashtag group with a name and hashtag string.')]
class CreateHashtagTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'hashtags' => ['required', 'string'],
]);
$hashtag = CreateHashtag::execute($request->user()->currentWorkspace, $validated);
return Response::structured($hashtag->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->required()->description('The hashtag group name.'),
'hashtags' => $schema->string()->required()->description('The hashtags string (e.g. "#tech #ai #startup").'),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Hashtag;
use App\Actions\Hashtag\DeleteHashtag;
use App\Models\WorkspaceHashtag;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Delete a hashtag group by ID.')]
class DeleteHashtagTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$hashtag = WorkspaceHashtag::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($request->validated(), 'hashtag_id'));
DeleteHashtag::execute($hashtag);
return Response::structured(['deleted' => true]);
}
public function schema(JsonSchema $schema): array
{
return [
'hashtag_id' => $schema->string()->required()->description('The hashtag group ID to delete.'),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Hashtag;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all hashtag groups for the current workspace.')]
class ListHashtagsTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$hashtags = $request->user()->currentWorkspace->hashtags()->latest()->get();
return Response::structured($hashtags->toArray());
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Hashtag;
use App\Actions\Hashtag\UpdateHashtag;
use App\Models\WorkspaceHashtag;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Update a hashtag group name or hashtags.')]
class UpdateHashtagTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'hashtag_id' => ['required', 'string'],
'name' => ['required', 'string', 'max:255'],
'hashtags' => ['required', 'string'],
]);
$hashtag = WorkspaceHashtag::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($validated, 'hashtag_id'));
$hashtag = UpdateHashtag::execute($hashtag, $validated);
return Response::structured($hashtag->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'hashtag_id' => $schema->string()->required()->description('The hashtag group ID.'),
'name' => $schema->string()->required()->description('The new name.'),
'hashtags' => $schema->string()->required()->description('The new hashtags string.'),
];
}
}

View file

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Label;
use App\Actions\Label\CreateLabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new label with a name and hex color.')]
class CreateLabelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$label = CreateLabel::execute($request->user()->currentWorkspace, $validated);
return Response::structured($label->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->required()->description('The label name.'),
'color' => $schema->string()->required()->description('Hex color code (e.g. #FF5733).'),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Label;
use App\Actions\Label\DeleteLabel;
use App\Models\WorkspaceLabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Delete a label by ID.')]
class DeleteLabelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($request->validated(), 'label_id'));
DeleteLabel::execute($label);
return Response::structured(['deleted' => true]);
}
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->required()->description('The label ID to delete.'),
];
}
}

View file

@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Label;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all labels for the current workspace.')]
class ListLabelsTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$labels = $request->user()->currentWorkspace->labels()->latest()->get();
return Response::structured($labels->toArray());
}
}

View file

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Label;
use App\Actions\Label\UpdateLabel;
use App\Models\WorkspaceLabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Update a label name or color.')]
class UpdateLabelTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$validated = $request->validate([
'label_id' => ['required', 'string'],
'name' => ['required', 'string', 'max:255'],
'color' => ['required', 'string', 'max:7', 'regex:/^#[0-9A-Fa-f]{6}$/'],
]);
$label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($validated, 'label_id'));
$label = UpdateLabel::execute($label, $validated);
return Response::structured($label->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'label_id' => $schema->string()->required()->description('The label ID.'),
'name' => $schema->string()->required()->description('The new name.'),
'color' => $schema->string()->required()->description('Hex color code (e.g. #FF5733).'),
];
}
}

View file

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use App\Actions\Post\CreatePost;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new draft post in the current workspace. A post platform entry is created for each connected social account.')]
class CreatePostTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$workspace = $request->user()->currentWorkspace;
$post = CreatePost::execute($workspace, $request->user(), $request->validated());
$post->load(['postPlatforms.socialAccount']);
return Response::structured($post->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'date' => $schema->string()->description('The scheduled date (Y-m-d). Defaults to today.'),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use App\Actions\Post\DeletePost;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Delete a post by ID.')]
class DeletePostTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
->findOrFail(data_get($request->validated(), 'post_id'));
DeletePost::execute($post);
return Response::structured(['deleted' => true]);
}
public function schema(JsonSchema $schema): array
{
return [
'post_id' => $schema->string()->required()->description('The post ID to delete.'),
];
}
}

View file

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Get a specific post by ID with all its platform content and labels.')]
class GetPostTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
->with(['postPlatforms.socialAccount', 'postPlatforms.media', 'labels'])
->findOrFail(data_get($request->validated(), 'post_id'));
return Response::structured($post->toArray());
}
public function schema(JsonSchema $schema): array
{
return [
'post_id' => $schema->string()->required()->description('The post ID to retrieve.'),
];
}
}

View file

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Post;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all posts for the current workspace. Returns posts with their platforms, status, and scheduled date.')]
class ListPostsTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$posts = $request->user()->currentWorkspace
->posts()
->with(['postPlatforms.socialAccount', 'labels'])
->latest('scheduled_at')
->paginate(50);
return Response::structured($posts->toArray());
}
}

View file

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Mcp\Tools\Workspace;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Get the current workspace details including name and timezone.')]
class GetWorkspaceTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
return Response::structured($request->user()->currentWorkspace->toArray());
}
}

View file

@ -21,6 +21,7 @@
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Cache\RateLimiting\Limit;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
@ -49,7 +50,10 @@ class AppServiceProvider extends ServiceProvider
*/
public function register(): void
{
//
if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) {
$this->app->register(\Laravel\Telescope\TelescopeServiceProvider::class);
$this->app->register(TelescopeServiceProvider::class);
}
}
/**
@ -123,6 +127,7 @@ protected function configureDefaults(): void
// Disable wrapping of JSON resources
JsonResource::withoutWrapping();
Model::shouldBeStrict(! $this->app->isProduction());
DB::prohibitDestructiveCommands(
app()->isProduction(),

View file

@ -0,0 +1,65 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Support\Facades\Gate;
use Laravel\Telescope\IncomingEntry;
use Laravel\Telescope\Telescope;
use Laravel\Telescope\TelescopeApplicationServiceProvider;
class TelescopeServiceProvider extends TelescopeApplicationServiceProvider
{
/**
* Register any application services.
*/
public function register(): void
{
// Telescope::night();
$this->hideSensitiveRequestDetails();
$isLocal = $this->app->environment('local');
Telescope::filter(function (IncomingEntry $entry) use ($isLocal) {
return $isLocal ||
$entry->isReportableException() ||
$entry->isFailedRequest() ||
$entry->isFailedJob() ||
$entry->isScheduledTask() ||
$entry->hasMonitoredTag();
});
}
/**
* Prevent sensitive request details from being logged by Telescope.
*/
protected function hideSensitiveRequestDetails(): void
{
if ($this->app->environment('local')) {
return;
}
Telescope::hideRequestParameters(['_token']);
Telescope::hideRequestHeaders([
'cookie',
'x-csrf-token',
'x-xsrf-token',
]);
}
/**
* Register the Telescope gate.
*
* This gate determines who can access Telescope in non-local environments.
*/
protected function gate(): void
{
Gate::define('viewTelescope', function (User $user) {
return in_array($user->email, [
//
]);
});
}
}

View file

@ -10,6 +10,7 @@
"cashier-stripe-development",
"laravel-best-practices",
"configuring-horizon",
"mcp-development",
"socialite-development",
"wayfinder-development",
"pest-testing",

View file

@ -4,6 +4,7 @@
use App\Http\Middleware\EnsureSubscribed;
use App\Http\Middleware\HandleAppearance;
use App\Http\Middleware\HandleInertiaRequests;
use App\Http\Middleware\Mcp\AuthenticateMcpToken;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
@ -35,6 +36,7 @@
$middleware->alias([
'subscribed' => EnsureSubscribed::class,
'api.auth' => AuthenticateApiToken::class,
'mcp.auth' => AuthenticateMcpToken::class,
]);
$middleware->preventRequestForgery(except: [

View file

@ -2,8 +2,10 @@
use App\Providers\AppServiceProvider;
use App\Providers\HorizonServiceProvider;
use App\Providers\TelescopeServiceProvider;
return [
AppServiceProvider::class,
HorizonServiceProvider::class,
TelescopeServiceProvider::class,
];

View file

@ -35,10 +35,12 @@
"require": {
"php": "^8.2",
"inertiajs/inertia-laravel": "^3.0",
"laravel/ai": "^0.4.2",
"laravel/boost": "^2.0",
"laravel/cashier": "^16.2",
"laravel/framework": "^13.0",
"laravel/horizon": "^5.42",
"laravel/mcp": "^0.6.4",
"laravel/nightwatch": "^1.22",
"laravel/reverb": "^1.0",
"laravel/socialite": "^5.24",
@ -59,6 +61,7 @@
"laravel/pail": "^1.2.2",
"laravel/pint": "^1.24",
"laravel/sail": "*",
"laravel/telescope": "^5.19",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.6",
"pestphp/pest": "^4.4",

216
composer.lock generated
View file

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "222ac114e81a021efda803dd571fb2d2",
"content-hash": "5408bdeb64bc5066a115e08ba7f378f3",
"packages": [
{
"name": "aws/aws-crt-php",
@ -1518,6 +1518,72 @@
},
"time": "2026-03-25T21:07:46+00:00"
},
{
"name": "laravel/ai",
"version": "v0.4.2",
"source": {
"type": "git",
"url": "https://github.com/laravel/ai.git",
"reference": "91441b6ae5bc995f21bb3043744860bd3043e78c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/ai/zipball/91441b6ae5bc995f21bb3043744860bd3043e78c",
"reference": "91441b6ae5bc995f21bb3043744860bd3043e78c",
"shasum": ""
},
"require": {
"illuminate/console": "^12.0|^13.0",
"illuminate/container": "^12.0|^13.0",
"illuminate/contracts": "^12.0|^13.0",
"illuminate/filesystem": "^12.0|^13.0",
"illuminate/json-schema": "^12.0|^13.0",
"illuminate/support": "^12.0|^13.0",
"laravel/prompts": "^0.3.6",
"laravel/serializable-closure": "^2.0",
"php": "^8.3",
"prism-php/prism": "^0.99.0"
},
"require-dev": {
"laravel/pint": "^1.26",
"mockery/mockery": "^1.6.12",
"orchestra/testbench": "^10.6|^11.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Ai\\AiServiceProvider"
]
},
"branch-alias": {
"dev-master": "1.x-dev"
}
},
"autoload": {
"files": [
"functions.php"
],
"psr-4": {
"Laravel\\Ai\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"description": "The official AI SDK for Laravel.",
"homepage": "https://github.com/laravel/ai",
"keywords": [
"ai",
"laravel"
],
"support": {
"issues": "https://github.com/laravel/ai/issues",
"source": "https://github.com/laravel/ai"
},
"time": "2026-03-27T18:24:41+00:00"
},
{
"name": "laravel/boost",
"version": "v2.4.1",
@ -4531,6 +4597,85 @@
],
"time": "2026-03-09T20:33:04+00:00"
},
{
"name": "prism-php/prism",
"version": "v0.99.22",
"source": {
"type": "git",
"url": "https://github.com/prism-php/prism.git",
"reference": "989f67567aef69c613eae6e932d615fb96e2f5d7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/prism-php/prism/zipball/989f67567aef69c613eae6e932d615fb96e2f5d7",
"reference": "989f67567aef69c613eae6e932d615fb96e2f5d7",
"shasum": ""
},
"require": {
"ext-fileinfo": "*",
"laravel/framework": "^11.0|^12.0|^13.0",
"php": "^8.2"
},
"require-dev": {
"brianium/paratest": "^7.8.4",
"laravel/mcp": "^0.6.0",
"laravel/pint": "^1.14",
"mockery/mockery": "^1.6",
"orchestra/testbench": "^10",
"pestphp/pest": "^3.0",
"pestphp/pest-plugin-arch": "^3.0",
"pestphp/pest-plugin-laravel": "^3.0",
"phpstan/extension-installer": "^1.3",
"phpstan/phpdoc-parser": "^2.0",
"phpstan/phpstan": "2.1.34",
"phpstan/phpstan-deprecation-rules": "^2.0",
"projektgopher/whisky": "^0.7.0",
"rector/rector": "2.3.3",
"spatie/laravel-ray": "^1.39",
"symplify/rule-doc-generator-contracts": "^11.2"
},
"type": "library",
"extra": {
"laravel": {
"aliases": {
"PrismServer": "Prism\\Prism\\Facades\\PrismServer"
},
"providers": [
"Prism\\Prism\\PrismServiceProvider"
]
}
},
"autoload": {
"files": [
"src/helpers.php"
],
"psr-4": {
"Prism\\Prism\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "TJ Miller",
"email": "hello@echolabs.dev"
}
],
"description": "A powerful Laravel package for integrating Large Language Models (LLMs) into your applications.",
"support": {
"issues": "https://github.com/prism-php/prism/issues",
"source": "https://github.com/prism-php/prism/tree/v0.99.22"
},
"funding": [
{
"url": "https://github.com/sixlive",
"type": "github"
}
],
"time": "2026-03-12T17:55:23+00:00"
},
{
"name": "psr/clock",
"version": "1.0.0",
@ -9984,6 +10129,75 @@
},
"time": "2026-03-23T15:56:34+00:00"
},
{
"name": "laravel/telescope",
"version": "v5.19.0",
"source": {
"type": "git",
"url": "https://github.com/laravel/telescope.git",
"reference": "5e95df170d14e03dd74c4b744969cf01f67a050b"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/telescope/zipball/5e95df170d14e03dd74c4b744969cf01f67a050b",
"reference": "5e95df170d14e03dd74c4b744969cf01f67a050b",
"shasum": ""
},
"require": {
"ext-json": "*",
"laravel/framework": "^8.37|^9.0|^10.0|^11.0|^12.0|^13.0",
"laravel/sentinel": "^1.0",
"php": "^8.0",
"symfony/console": "^5.3|^6.0|^7.0|^8.0",
"symfony/var-dumper": "^5.0|^6.0|^7.0|^8.0"
},
"require-dev": {
"ext-gd": "*",
"guzzlehttp/guzzle": "^6.0|^7.0",
"laravel/octane": "^1.4|^2.0",
"orchestra/testbench": "^6.47.1|^7.55|^8.36|^9.15|^10.8|^11.0",
"phpstan/phpstan": "^1.10"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Telescope\\TelescopeServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Telescope\\": "src/",
"Laravel\\Telescope\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
},
{
"name": "Mohamed Said",
"email": "mohamed@laravel.com"
}
],
"description": "An elegant debug assistant for the Laravel framework.",
"keywords": [
"debugging",
"laravel",
"monitoring"
],
"support": {
"issues": "https://github.com/laravel/telescope/issues",
"source": "https://github.com/laravel/telescope/tree/v5.19.0"
},
"time": "2026-03-24T18:37:14+00:00"
},
{
"name": "mockery/mockery",
"version": "1.6.12",

130
config/ai.php Normal file
View file

@ -0,0 +1,130 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Default AI Provider Names
|--------------------------------------------------------------------------
|
| Here you may specify which of the AI providers below should be the
| default for AI operations when no explicit provider is provided
| for the operation. This should be any provider defined below.
|
*/
'default' => 'openai',
'default_for_images' => 'gemini',
'default_for_audio' => 'openai',
'default_for_transcription' => 'openai',
'default_for_embeddings' => 'openai',
'default_for_reranking' => 'cohere',
/*
|--------------------------------------------------------------------------
| Caching
|--------------------------------------------------------------------------
|
| Below you may configure caching strategies for AI related operations
| such as embedding generation. You are free to adjust these values
| based on your application's available caching stores and needs.
|
*/
'caching' => [
'embeddings' => [
'cache' => false,
'store' => env('CACHE_STORE', 'database'),
],
],
/*
|--------------------------------------------------------------------------
| AI Providers
|--------------------------------------------------------------------------
|
| Below are each of your AI providers defined for this application. Each
| represents an AI provider and API key combination which can be used
| to perform tasks like text, image, and audio creation via agents.
|
*/
'providers' => [
'anthropic' => [
'driver' => 'anthropic',
'key' => env('ANTHROPIC_API_KEY'),
],
'azure' => [
'driver' => 'azure',
'key' => env('AZURE_OPENAI_API_KEY'),
'url' => env('AZURE_OPENAI_URL'),
'api_version' => env('AZURE_OPENAI_API_VERSION', '2024-10-21'),
'deployment' => env('AZURE_OPENAI_DEPLOYMENT', 'gpt-4o'),
'embedding_deployment' => env('AZURE_OPENAI_EMBEDDING_DEPLOYMENT', 'text-embedding-3-small'),
],
'cohere' => [
'driver' => 'cohere',
'key' => env('COHERE_API_KEY'),
],
'deepseek' => [
'driver' => 'deepseek',
'key' => env('DEEPSEEK_API_KEY'),
],
'eleven' => [
'driver' => 'eleven',
'key' => env('ELEVENLABS_API_KEY'),
],
'gemini' => [
'driver' => 'gemini',
'key' => env('GEMINI_API_KEY'),
],
'groq' => [
'driver' => 'groq',
'key' => env('GROQ_API_KEY'),
],
'jina' => [
'driver' => 'jina',
'key' => env('JINA_API_KEY'),
],
'mistral' => [
'driver' => 'mistral',
'key' => env('MISTRAL_API_KEY'),
],
'ollama' => [
'driver' => 'ollama',
'key' => env('OLLAMA_API_KEY', ''),
'url' => env('OLLAMA_BASE_URL', 'http://localhost:11434'),
],
'openai' => [
'driver' => 'openai',
'key' => env('OPENAI_API_KEY'),
'url' => env('OPENAI_URL', 'https://api.openai.com/v1'),
],
'openrouter' => [
'driver' => 'openrouter',
'key' => env('OPENROUTER_API_KEY'),
],
'voyageai' => [
'driver' => 'voyageai',
'key' => env('VOYAGEAI_API_KEY'),
],
'xai' => [
'driver' => 'xai',
'key' => env('XAI_API_KEY'),
],
],
];

212
config/telescope.php Normal file
View file

@ -0,0 +1,212 @@
<?php
use Laravel\Telescope\Http\Middleware\Authorize;
use Laravel\Telescope\Watchers;
return [
/*
|--------------------------------------------------------------------------
| Telescope Master Switch
|--------------------------------------------------------------------------
|
| This option may be used to disable all Telescope watchers regardless
| of their individual configuration, which simply provides a single
| and convenient way to enable or disable Telescope data storage.
|
*/
'enabled' => env('TELESCOPE_ENABLED', true),
/*
|--------------------------------------------------------------------------
| Telescope Domain
|--------------------------------------------------------------------------
|
| This is the subdomain where Telescope will be accessible from. If the
| setting is null, Telescope will reside under the same domain as the
| application. Otherwise, this value will be used as the subdomain.
|
*/
'domain' => env('TELESCOPE_DOMAIN'),
/*
|--------------------------------------------------------------------------
| Telescope Path
|--------------------------------------------------------------------------
|
| This is the URI path where Telescope will be accessible from. Feel free
| to change this path to anything you like. Note that the URI will not
| affect the paths of its internal API that aren't exposed to users.
|
*/
'path' => env('TELESCOPE_PATH', 'telescope'),
/*
|--------------------------------------------------------------------------
| Telescope Storage Driver
|--------------------------------------------------------------------------
|
| This configuration options determines the storage driver that will
| be used to store Telescope's data. In addition, you may set any
| custom options as needed by the particular driver you choose.
|
*/
'driver' => env('TELESCOPE_DRIVER', 'database'),
'storage' => [
'database' => [
'connection' => env('DB_CONNECTION', 'mysql'),
'chunk' => 1000,
],
],
/*
|--------------------------------------------------------------------------
| Telescope Queue
|--------------------------------------------------------------------------
|
| This configuration options determines the queue connection and queue
| which will be used to process ProcessPendingUpdate jobs. This can
| be changed if you would prefer to use a non-default connection.
|
*/
'queue' => [
'connection' => env('TELESCOPE_QUEUE_CONNECTION'),
'queue' => env('TELESCOPE_QUEUE'),
'delay' => env('TELESCOPE_QUEUE_DELAY', 10),
],
/*
|--------------------------------------------------------------------------
| Telescope Route Middleware
|--------------------------------------------------------------------------
|
| These middleware will be assigned to every Telescope route, giving you
| the chance to add your own middleware to this list or change any of
| the existing middleware. Or, you can simply stick with this list.
|
*/
'middleware' => [
'web',
Authorize::class,
],
/*
|--------------------------------------------------------------------------
| Allowed / Ignored Paths & Commands
|--------------------------------------------------------------------------
|
| The following array lists the URI paths and Artisan commands that will
| not be watched by Telescope. In addition to this list, some Laravel
| commands, like migrations and queue commands, are always ignored.
|
*/
'only_paths' => [
// 'api/*'
],
'ignore_paths' => [
'livewire*',
'nova-api*',
'pulse*',
'_boost*',
'.well-known*',
],
'ignore_commands' => [
//
],
/*
|--------------------------------------------------------------------------
| Telescope Watchers
|--------------------------------------------------------------------------
|
| The following array lists the "watchers" that will be registered with
| Telescope. The watchers gather the application's profile data when
| a request or task is executed. Feel free to customize this list.
|
*/
'watchers' => [
Watchers\BatchWatcher::class => env('TELESCOPE_BATCH_WATCHER', true),
Watchers\CacheWatcher::class => [
'enabled' => env('TELESCOPE_CACHE_WATCHER', true),
'hidden' => [],
'ignore' => [],
],
Watchers\ClientRequestWatcher::class => [
'enabled' => env('TELESCOPE_CLIENT_REQUEST_WATCHER', true),
'ignore_hosts' => [],
],
Watchers\CommandWatcher::class => [
'enabled' => env('TELESCOPE_COMMAND_WATCHER', true),
'ignore' => [],
],
Watchers\DumpWatcher::class => [
'enabled' => env('TELESCOPE_DUMP_WATCHER', true),
'always' => env('TELESCOPE_DUMP_WATCHER_ALWAYS', false),
],
Watchers\EventWatcher::class => [
'enabled' => env('TELESCOPE_EVENT_WATCHER', true),
'ignore' => [],
],
Watchers\ExceptionWatcher::class => env('TELESCOPE_EXCEPTION_WATCHER', true),
Watchers\GateWatcher::class => [
'enabled' => env('TELESCOPE_GATE_WATCHER', true),
'ignore_abilities' => [],
'ignore_packages' => true,
'ignore_paths' => [],
],
Watchers\JobWatcher::class => env('TELESCOPE_JOB_WATCHER', true),
Watchers\LogWatcher::class => [
'enabled' => env('TELESCOPE_LOG_WATCHER', true),
'level' => 'error',
],
Watchers\MailWatcher::class => env('TELESCOPE_MAIL_WATCHER', true),
Watchers\ModelWatcher::class => [
'enabled' => env('TELESCOPE_MODEL_WATCHER', true),
'events' => ['eloquent.*'],
'hydrations' => true,
],
Watchers\NotificationWatcher::class => env('TELESCOPE_NOTIFICATION_WATCHER', true),
Watchers\QueryWatcher::class => [
'enabled' => env('TELESCOPE_QUERY_WATCHER', true),
'ignore_packages' => true,
'ignore_paths' => [],
'slow' => 100,
],
Watchers\RedisWatcher::class => env('TELESCOPE_REDIS_WATCHER', true),
Watchers\RequestWatcher::class => [
'enabled' => env('TELESCOPE_REQUEST_WATCHER', true),
'size_limit' => env('TELESCOPE_RESPONSE_SIZE_LIMIT', 64),
'ignore_http_methods' => [],
'ignore_status_codes' => [],
],
Watchers\ScheduleWatcher::class => env('TELESCOPE_SCHEDULE_WATCHER', true),
Watchers\ViewWatcher::class => env('TELESCOPE_VIEW_WATCHER', true),
],
];

View file

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Laravel\Ai\Migrations\AiMigration;
return new class extends AiMigration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('agent_conversations', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->foreignId('user_id')->nullable();
$table->string('title');
$table->timestamps();
$table->index(['user_id', 'updated_at']);
});
Schema::create('agent_conversation_messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('conversation_id', 36)->index();
$table->foreignId('user_id')->nullable();
$table->string('agent');
$table->string('role', 25);
$table->text('content');
$table->text('attachments');
$table->text('tool_calls');
$table->text('tool_results');
$table->text('usage');
$table->text('meta');
$table->timestamps();
$table->index(['conversation_id', 'user_id', 'updated_at'], 'conversation_index');
$table->index(['user_id']);
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('agent_conversations');
Schema::dropIfExists('agent_conversation_messages');
}
};

View file

@ -0,0 +1,70 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return config('telescope.storage.database.connection');
}
/**
* Run the migrations.
*/
public function up(): void
{
$schema = Schema::connection($this->getConnection());
$schema->create('telescope_entries', function (Blueprint $table) {
$table->bigIncrements('sequence');
$table->uuid('uuid');
$table->uuid('batch_id');
$table->string('family_hash')->nullable();
$table->boolean('should_display_on_index')->default(true);
$table->string('type', 20);
$table->longText('content');
$table->dateTime('created_at')->nullable();
$table->unique('uuid');
$table->index('batch_id');
$table->index('family_hash');
$table->index('created_at');
$table->index(['type', 'should_display_on_index']);
});
$schema->create('telescope_entries_tags', function (Blueprint $table) {
$table->uuid('entry_uuid');
$table->string('tag');
$table->primary(['entry_uuid', 'tag']);
$table->index('tag');
$table->foreign('entry_uuid')
->references('uuid')
->on('telescope_entries')
->cascadeOnDelete();
});
$schema->create('telescope_monitoring', function (Blueprint $table) {
$table->string('tag')->primary();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
$schema = Schema::connection($this->getConnection());
$schema->dropIfExists('telescope_entries_tags');
$schema->dropIfExists('telescope_entries');
$schema->dropIfExists('telescope_monitoring');
}
};

View file

@ -2,13 +2,16 @@
declare(strict_types=1);
use App\Mcp\Servers\TryPostServer;
use Illuminate\Support\Facades\Route;
use Laravel\Mcp\Facades\Mcp;
Route::group(
[
'domain' => 'mcp.'.parse_url(config('app.url'), PHP_URL_HOST),
],
function () {
//
Mcp::web('/trypost', TryPostServer::class)
->middleware('mcp.auth');
}
);

View file

@ -0,0 +1,20 @@
<?php
namespace {{ namespace }};
use Closure;
use Laravel\Ai\Prompts\AgentPrompt;
use Laravel\Ai\Responses\AgentResponse;
class {{ class }}
{
/**
* Handle the incoming prompt.
*/
public function handle(AgentPrompt $prompt, Closure $next)
{
return $next($prompt)->then(function (AgentResponse $response) {
// ...
});
}
}

44
stubs/agent.stub Normal file
View file

@ -0,0 +1,44 @@
<?php
namespace {{ namespace }};
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class {{ class }} implements Agent, Conversational, HasTools
{
use Promptable;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'You are a helpful assistant.';
}
/**
* Get the list of messages comprising the conversation so far.
*
* @return Message[]
*/
public function messages(): iterable
{
return [];
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
}

View file

@ -0,0 +1,56 @@
<?php
namespace {{ namespace }};
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Agent;
use Laravel\Ai\Contracts\Conversational;
use Laravel\Ai\Contracts\HasStructuredOutput;
use Laravel\Ai\Contracts\HasTools;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Messages\Message;
use Laravel\Ai\Promptable;
use Stringable;
class {{ class }} implements Agent, Conversational, HasStructuredOutput, HasTools
{
use Promptable;
/**
* Get the instructions that the agent should follow.
*/
public function instructions(): Stringable|string
{
return 'You are a helpful assistant.';
}
/**
* Get the list of messages comprising the conversation so far.
*
* @return Message[]
*/
public function messages(): iterable
{
return [];
}
/**
* Get the tools available to the agent.
*
* @return Tool[]
*/
public function tools(): iterable
{
return [];
}
/**
* Get the agent's structured output schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'value' => $schema->string()->required(),
];
}
}

37
stubs/tool.stub Normal file
View file

@ -0,0 +1,37 @@
<?php
namespace {{ namespace }};
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Ai\Contracts\Tool;
use Laravel\Ai\Tools\Request;
use Stringable;
class {{ class }} implements Tool
{
/**
* Get the description of the tool's purpose.
*/
public function description(): Stringable|string
{
return 'A description of the tool.';
}
/**
* Execute the tool.
*/
public function handle(Request $request): Stringable|string
{
//
}
/**
* Get the tool's schema definition.
*/
public function schema(JsonSchema $schema): array
{
return [
'value' => $schema->string()->required(),
];
}
}

View file

@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
use App\Enums\Post\Status as PostStatus;
use App\Enums\SocialAccount\Platform;
use App\Models\ApiToken;
use App\Models\Post;
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
beforeEach(function () {
$this->user = User::factory()->create();
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
$this->workspace->members()->attach($this->user->id, ['role' => 'owner']);
$plainToken = 'tp_'.Str::random(48);
$this->plainToken = $plainToken;
$this->apiToken = ApiToken::factory()->create([
'workspace_id' => $this->workspace->id,
'token_lookup' => substr($plainToken, 3, 16),
'token_hash' => Hash::make($plainToken),
]);
$this->socialAccount = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
]);
});
it('lists posts', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.index'))
->assertOk()
->assertJsonCount(1, 'data');
});
it('shows a post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertOk()
->assertJsonPath('id', $post->id);
});
it('cannot show post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->getJson(route('api.posts.show', $post))
->assertNotFound();
});
it('creates a post', function () {
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->postJson(route('api.posts.store'), [
'date' => now()->addDay()->format('Y-m-d'),
])
->assertCreated()
->assertJsonPath('status', PostStatus::Draft->value);
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
});
it('deletes a post', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->deleteJson(route('api.posts.destroy', $post))
->assertNoContent();
expect(Post::find($post->id))->toBeNull();
});
it('cannot delete post from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create([
'workspace_id' => $otherWorkspace->id,
'user_id' => $this->user->id,
]);
$this->withHeaders(['Authorization' => 'Bearer '.$this->plainToken])
->deleteJson(route('api.posts.destroy', $post))
->assertNotFound();
});