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
15 changes: 9 additions & 6 deletions app/Mcp/Servers/AdminNativePhpServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
use App\Mcp\Tools\Admin\AdminListBlogPosts;
use App\Mcp\Tools\Admin\AdminListCompanies;
use App\Mcp\Tools\Admin\AdminListSignups;
use App\Mcp\Tools\Admin\AdminPublishBlogPost;
use App\Mcp\Tools\Admin\AdminSalesSummary;
use App\Mcp\Tools\Admin\AdminSearchPlugins;
use App\Mcp\Tools\Admin\AdminSearchSupportTickets;
Expand All @@ -30,19 +31,20 @@ 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).
- Blog: create/update drafts; publish via admin-publish-blog-post (sets published_at like Filament; idempotent if already live). Slug changes refused once published. No unpublish tool yet.
- 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-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.
4. admin-publish-blog-post — publish by id/slug (optional published_at; default now; idempotent).
5. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts.
6. admin-list-signups / admin-search-users / admin-get-user — user support lookups.
7. admin-list-companies / admin-get-company — email-domain company rollups.
8. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets).
9. admin-search-support-tickets / admin-get-support-ticket — support summaries.
MARKDOWN;

/**
Expand All @@ -52,6 +54,7 @@ class AdminNativePhpServer extends Server
AdminCreateBlogPost::class,
AdminUploadMedia::class,
AdminUpdateBlogPost::class,
AdminPublishBlogPost::class,
AdminGetBlogPost::class,
AdminListBlogPosts::class,
AdminListSignups::class,
Expand Down
90 changes: 90 additions & 0 deletions app/Mcp/Tools/Admin/AdminPublishBlogPost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
<?php

namespace App\Mcp\Tools\Admin;

use App\Filament\Resources\ArticleResource;
use App\Mcp\Tools\Concerns\RequiresAdmin;
use App\Models\Article;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\JsonSchema\Types\Type;
use Illuminate\Support\Facades\Date;
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;

#[Name('admin-publish-blog-post')]
#[Description('Publish a blog article by id or slug the same way Filament does (sets published_at via Article::publish). Optional published_at (ISO8601); defaults to now. Idempotent if already published — returns current state without error. Does not unpublish.')]
class AdminPublishBlogPost extends Tool
{
use RequiresAdmin;

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

$validated = $request->validate([
'id' => ['nullable', 'integer', 'min:1'],
'slug' => ['nullable', 'string', 'max:255'],
'published_at' => ['nullable', 'date'],
]);

if (empty($validated['id']) && empty($validated['slug'])) {
return Response::error('Provide id or slug.');
}

$article = Article::query()
->when(! empty($validated['id']), fn ($q) => $q->where('id', $validated['id']))
->when(empty($validated['id']) && ! empty($validated['slug']), fn ($q) => $q->where('slug', $validated['slug']))
->first();

if (! $article) {
return Response::error('Article not found.');
}

if (! $article->isPublished()) {
$on = array_key_exists('published_at', $validated) && filled($validated['published_at'] ?? null)
? Date::parse($validated['published_at'])
: null;

$article->publish($on);
$article = $article->fresh();
}

$editUrl = null;

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

$publicUrl = route('article', $article);

return Response::text($this->toJson([
'id' => $article->id,
'slug' => $article->slug,
'title' => $article->title,
'published' => $article->isPublished(),
'published_at' => optional($article->published_at)?->toIso8601String(),
'admin_edit_url' => $editUrl,
'public_url' => $publicUrl,
'preview_url' => $publicUrl,
]));
}

/**
* @return array<string, Type>
*/
public function schema(JsonSchema $schema): array
{
return [
'id' => $schema->integer()->description('Article id.'),
'slug' => $schema->string()->description('Article slug (used when id is omitted).'),
'published_at' => $schema->string()->description('Optional ISO8601 publish time. Defaults to now when omitted. Ignored when the article is already published (idempotent).'),
];
}
}
102 changes: 102 additions & 0 deletions tests/Feature/Mcp/AdminMcpOAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,7 @@ public function test_admin_can_connect_via_oauth_and_use_admin_mcp_server(): voi
$this->assertContains('admin-create-blog-post', $names);
$this->assertContains('admin-upload-media', $names);
$this->assertContains('admin-update-blog-post', $names);
$this->assertContains('admin-publish-blog-post', $names);
$this->assertContains('admin-list-signups', $names);
$this->assertContains('admin-list-companies', $names);
$this->assertContains('admin-search-plugins', $names);
Expand Down Expand Up @@ -411,4 +412,105 @@ public function test_update_blog_post_can_rename_draft_slug(): void
$this->assertSame('new-draft-slug', $payload['slug']);
$this->assertSame('new-draft-slug', $article->fresh()->slug);
}

public function test_publish_blog_post_publishes_draft_by_id(): void
{
$article = Article::factory()->create([
'author_id' => $this->admin->id,
'slug' => 'draft-to-publish',
'title' => 'Ready to Ship',
'published_at' => null,
]);

$tokens = $this->issueMcpOAuthTokens($this->admin);
$response = $this->callMcpTool($tokens['access_token'], 'admin-publish-blog-post', [
'id' => $article->id,
])->assertOk();

$this->assertFalse($response->json('result.isError'));
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);

$this->assertSame($article->id, $payload['id']);
$this->assertSame('draft-to-publish', $payload['slug']);
$this->assertSame('Ready to Ship', $payload['title']);
$this->assertTrue($payload['published']);
$this->assertNotNull($payload['published_at']);
$this->assertNotEmpty($payload['admin_edit_url']);
$this->assertNotEmpty($payload['public_url']);
$this->assertStringContainsString('draft-to-publish', $payload['public_url']);

$article->refresh();
$this->assertNotNull($article->published_at);
$this->assertTrue($article->isPublished());
}

public function test_publish_blog_post_by_slug_with_custom_published_at(): void
{
$article = Article::factory()->create([
'author_id' => $this->admin->id,
'slug' => 'schedule-via-mcp',
'published_at' => null,
]);

$tokens = $this->issueMcpOAuthTokens($this->admin);
$when = now()->subDay()->startOfSecond();
$response = $this->callMcpTool($tokens['access_token'], 'admin-publish-blog-post', [
'slug' => 'schedule-via-mcp',
'published_at' => $when->toIso8601String(),
])->assertOk();

$this->assertFalse($response->json('result.isError'));
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);

$this->assertTrue($payload['published']);
$article->refresh();
$this->assertTrue($article->published_at->equalTo($when));
}

public function test_publish_blog_post_is_idempotent_when_already_published(): void
{
$original = now()->subDays(3)->startOfSecond();
$article = Article::factory()->create([
'author_id' => $this->admin->id,
'slug' => 'already-live',
'title' => 'Already Live',
'published_at' => $original,
]);

$tokens = $this->issueMcpOAuthTokens($this->admin);
$response = $this->callMcpTool($tokens['access_token'], 'admin-publish-blog-post', [
'id' => $article->id,
'published_at' => now()->toIso8601String(),
])->assertOk();

$this->assertFalse($response->json('result.isError'));
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);

$this->assertTrue($payload['published']);
$this->assertSame('Already Live', $payload['title']);
$article->refresh();
$this->assertTrue($article->published_at->equalTo($original));
}

public function test_publish_blog_post_missing_article(): void
{
$tokens = $this->issueMcpOAuthTokens($this->admin);
$response = $this->callMcpTool($tokens['access_token'], 'admin-publish-blog-post', [
'id' => 999999,
])->assertOk();

$this->assertTrue($response->json('result.isError'));
$text = (string) data_get($response->json(), 'result.content.0.text');
$this->assertStringContainsString('Article not found', $text);
}

public function test_publish_blog_post_requires_id_or_slug(): void
{
$tokens = $this->issueMcpOAuthTokens($this->admin);
$response = $this->callMcpTool($tokens['access_token'], 'admin-publish-blog-post', [])->assertOk();

$this->assertTrue($response->json('result.isError'));
$text = (string) data_get($response->json(), 'result.content.0.text');
$this->assertStringContainsString('Provide id or slug', $text);
}
}