Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions app/Mcp/Servers/AdminNativePhpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use App\Mcp\Tools\Admin\AdminSearchSupportTickets;
use App\Mcp\Tools\Admin\AdminSearchUsers;
use App\Mcp\Tools\Admin\AdminUpdateBlogPost;
use App\Mcp\Tools\Admin\AdminUploadMedia;
use Laravel\Mcp\Server;
use Laravel\Mcp\Server\Tool;

Expand All @@ -30,23 +31,26 @@ class AdminNativePhpServer extends Server
Rules:
- Never return passwords, remember tokens, GitHub tokens, license keys, Stripe secrets, or raw API credentials.
- Blog: create/update unpublished drafts in v1 (no publish tool; slug changes refused once published).
- Media: upload via admin-upload-media to the public disk (default directory website-images; pass directory: "blog/heroes" for Filament article heroes). Optionally attach in one shot with article_id/slug only when directory is blog/heroes; or set hero on admin-update-blog-post via media_id/path or URL.
- Other tools are read-only ops helpers (signups, users, companies, plugins, sales summaries, support).

Tools:
1. admin-create-blog-post — create an unpublished article.
2. admin-update-blog-post — patch title/content/excerpt/slug (no publish/unpublish).
3. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts.
4. admin-list-signups / admin-search-users / admin-get-user — user support lookups.
5. admin-list-companies / admin-get-company — email-domain company rollups.
6. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets).
7. admin-search-support-tickets / admin-get-support-ticket — support summaries.
2. admin-upload-media — upload an image (base64 → public disk WebP); optional directory (default website-images); for heroes pass directory: "blog/heroes" and optional article_id/slug attach.
3. admin-update-blog-post — patch title/content/excerpt/slug/hero (no publish/unpublish).
4. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts.
5. admin-list-signups / admin-search-users / admin-get-user — user support lookups.
6. admin-list-companies / admin-get-company — email-domain company rollups.
7. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets).
8. admin-search-support-tickets / admin-get-support-ticket — support summaries.
MARKDOWN;

/**
* @var array<int, class-string<Tool>>
*/
protected array $tools = [
AdminCreateBlogPost::class,
AdminUploadMedia::class,
AdminUpdateBlogPost::class,
AdminGetBlogPost::class,
AdminListBlogPosts::class,
Expand Down
2 changes: 2 additions & 0 deletions app/Mcp/Tools/Admin/AdminGetBlogPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ public function handle(Request $request): Response
'title' => $article->title,
'excerpt' => $article->excerpt,
'content' => $article->content,
'hero_image' => $article->hero_image,
'hero_image_url' => $article->getHeroImageUrl(),
'published' => $article->isPublished(),
'published_at' => optional($article->published_at)?->toIso8601String(),
'author' => [
Expand Down
56 changes: 51 additions & 5 deletions app/Mcp/Tools/Admin/AdminUpdateBlogPost.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
use App\Filament\Resources\ArticleResource;
use App\Mcp\Tools\Concerns\RequiresAdmin;
use App\Models\Article;
use App\Services\AdminArticleMediaService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Str;
Expand All @@ -13,13 +14,16 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use RuntimeException;

#[Name('admin-update-blog-post')]
#[Description('Update an existing blog article by id or slug. Patches only provided fields (title, content, excerpt, slug). Does not publish or unpublish. When id is provided, slug is treated as the new slug (drafts only; refused if published). When only slug is provided, it identifies the article.')]
#[Description('Update an existing blog article by id or slug. Patches only provided fields (title, content, excerpt, slug, hero image). Hero can be set via media_id/hero_image_path (from admin-upload-media) or hero_image_url. Does not publish or unpublish. When id is provided, slug is treated as the new slug (drafts only; refused if published). When only slug is provided, it identifies the article.')]
class AdminUpdateBlogPost extends Tool
{
use RequiresAdmin;

public function __construct(protected AdminArticleMediaService $media) {}

public function handle(Request $request): Response
{
if ($denied = $this->ensureAdmin($request)) {
Expand All @@ -32,6 +36,9 @@ public function handle(Request $request): Response
'title' => ['nullable', 'string', 'max:255'],
'content' => ['nullable', 'string'],
'excerpt' => ['nullable', 'string', 'max:5000'],
'media_id' => ['nullable', 'string', 'max:500'],
'hero_image_path' => ['nullable', 'string', 'max:500'],
'hero_image_url' => ['nullable', 'string', 'max:2000'],
]);

if (empty($validated['id']) && empty($validated['slug'])) {
Expand All @@ -44,9 +51,12 @@ public function handle(Request $request): Response
$updatingExcerpt = array_key_exists('excerpt', $input);
// Slug is an update field only when identifying by id (same arg name as create).
$updatingSlug = ! empty($validated['id']) && array_key_exists('slug', $input);
$updatingHero = array_key_exists('media_id', $input)
|| array_key_exists('hero_image_path', $input)
|| array_key_exists('hero_image_url', $input);

if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug) {
return Response::error('Provide at least one field to update: title, content, excerpt, or slug.');
if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug && ! $updatingHero) {
return Response::error('Provide at least one field to update: title, content, excerpt, slug, media_id, hero_image_path, or hero_image_url.');
}

$article = Article::query()
Expand Down Expand Up @@ -105,8 +115,38 @@ public function handle(Request $request): Response
$updates['slug'] = $slug;
}

$article->fill($updates);
$article->save();
if ($updates !== []) {
$article->fill($updates);
$article->save();
}

$heroMeta = null;

if ($updatingHero) {
$mediaId = $validated['media_id'] ?? $validated['hero_image_path'] ?? null;
$heroUrl = $validated['hero_image_url'] ?? null;

if (filled($mediaId) && filled($heroUrl)) {
return Response::error('Provide only one of media_id/hero_image_path or hero_image_url.');
}

if (! filled($mediaId) && ! filled($heroUrl)) {
return Response::error('media_id, hero_image_path, or hero_image_url cannot be empty.');
}

try {
$stored = filled($mediaId)
? $this->media->resolveExistingMedia((string) $mediaId)
: $this->media->storeHeroFromUrl((string) $heroUrl);

$article = $this->media->attachHeroToArticle($article->fresh(), $stored['path']);
$heroMeta = $stored;
} catch (RuntimeException $e) {
return Response::error($e->getMessage());
}
}

$article = $article->fresh();

$editUrl = null;

Expand All @@ -122,6 +162,9 @@ public function handle(Request $request): Response
'title' => $article->title,
'excerpt' => $article->excerpt,
'content' => $article->content,
'hero_image' => $article->hero_image,
'hero_image_url' => $article->getHeroImageUrl(),
'media' => $heroMeta,
'published' => $article->isPublished(),
'published_at' => optional($article->published_at)?->toIso8601String(),
'author_id' => $article->author_id,
Expand All @@ -144,6 +187,9 @@ public function schema(JsonSchema $schema): array
'title' => $schema->string()->description('Optional new title.'),
'content' => $schema->string()->description('Optional new Markdown body.'),
'excerpt' => $schema->string()->description('Optional new excerpt.'),
'media_id' => $schema->string()->description('Optional media_id/path from admin-upload-media (under blog/heroes) to set as the hero image.'),
'hero_image_path' => $schema->string()->description('Alias for media_id: public-disk relative path under blog/heroes.'),
'hero_image_url' => $schema->string()->description('Optional http(s) URL to download and set as the hero image (re-encoded to WebP on the public disk).'),
];
}
}
132 changes: 132 additions & 0 deletions app/Mcp/Tools/Admin/AdminUploadMedia.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
<?php

namespace App\Mcp\Tools\Admin;

use App\Filament\Resources\ArticleResource;
use App\Mcp\Tools\Concerns\RequiresAdmin;
use App\Models\Article;
use App\Services\AdminArticleMediaService;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Attributes\Name;
use Laravel\Mcp\Server\Tool;
use RuntimeException;

#[Name('admin-upload-media')]
#[Description('Upload an image to the public media disk (WebP re-encode). Optional directory is a relative path under the public disk (no "..", no absolute paths); nested paths like blog/heroes are allowed. Default directory is website-images. Attaching via article_id/slug does NOT force blog/heroes — pass directory: "blog/heroes" when uploading a blog hero/featured image. Prefer image/webp; jpeg/png allowed. Does not publish.')]
class AdminUploadMedia extends Tool
{
use RequiresAdmin;

public function __construct(protected AdminArticleMediaService $media) {}

public function handle(Request $request): Response
{
if ($denied = $this->ensureAdmin($request)) {
return $denied;
}

$validated = $request->validate([
'filename' => ['required', 'string', 'max:255'],
'contentType' => ['required', 'string', 'max:100'],
'content' => ['required', 'string'],
'directory' => ['nullable', 'string', 'max:255'],
'article_id' => ['nullable', 'integer', 'min:1'],
'slug' => ['nullable', 'string', 'max:255'],
]);

try {
$stored = $this->media->storeHeroFromBase64(
$validated['content'],
$validated['contentType'],
$validated['filename'],
$validated['directory'] ?? null,
);
} catch (RuntimeException $e) {
return Response::error($e->getMessage());
}

$payload = [
'path' => $stored['path'],
'media_id' => $stored['media_id'],
'url' => $stored['url'],
'content_type' => $stored['content_type'],
'bytes' => $stored['bytes'],
'width' => $stored['width'],
'height' => $stored['height'],
'original_filename' => $stored['original_filename'],
'disk' => AdminArticleMediaService::DISK,
'directory' => $stored['directory'],
'attached' => false,
'article' => null,
];

$articleId = $validated['article_id'] ?? null;
$slug = $validated['slug'] ?? null;

if ($articleId || filled($slug)) {
$article = Article::query()
->when($articleId, fn ($q) => $q->where('id', $articleId))
->when(! $articleId && filled($slug), fn ($q) => $q->where('slug', $slug))
->first();

if (! $article) {
return Response::error('Image uploaded, but article not found for attach. media_id='.$stored['media_id']);
}

try {
$article = $this->media->attachHeroToArticle($article, $stored['path']);
} catch (RuntimeException $e) {
return Response::error('Image uploaded, but attach failed: '.$e->getMessage().' media_id='.$stored['media_id']);
}

$payload['attached'] = true;
$payload['article'] = $this->articlePayload($article);
}

return Response::text($this->toJson($payload));
}

/**
* @return array<string, mixed>
*/
protected function articlePayload(Article $article): array
{
$editUrl = null;

try {
$editUrl = ArticleResource::getUrl('edit', ['record' => $article]);
} catch (\Throwable) {
$editUrl = url('/admin/articles/'.$article->id.'/edit');
}

return [
'id' => $article->id,
'slug' => $article->slug,
'title' => $article->title,
'hero_image' => $article->hero_image,
'hero_image_url' => $article->getHeroImageUrl(),
'published' => $article->isPublished(),
'admin_edit_url' => $editUrl,
'preview_url' => route('article', $article),
];
}

/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'filename' => $schema->string()->description('Original filename (used for logging; stored name is a UUID .webp).')->required(),
'contentType' => $schema->string()->description('MIME type: image/webp (preferred), image/jpeg, or image/png.')->required(),
'content' => $schema->string()->description('Base64-encoded image bytes with no data: URI prefix. Decoded size hard-capped at a few MB; re-encoded to WebP.')->required(),
'directory' => $schema->string()->description('Optional relative path under the public media disk (no "..", no absolute paths). Nested paths allowed (e.g. blog/heroes). Defaults to website-images. When attaching as an article hero via article_id/slug, pass directory: "blog/heroes" — attach does not force that folder.'),
'article_id' => $schema->integer()->description('Optional article id to attach this upload as the hero/featured image. Requires directory "blog/heroes" (or a path already under it).'),
'slug' => $schema->string()->description('Optional article slug to attach as hero when article_id is omitted. Requires directory "blog/heroes".'),
];
}
}
Loading