diff --git a/app/Mcp/Servers/AdminNativePhpServer.php b/app/Mcp/Servers/AdminNativePhpServer.php index cab5ae5b..e47f767c 100644 --- a/app/Mcp/Servers/AdminNativePhpServer.php +++ b/app/Mcp/Servers/AdminNativePhpServer.php @@ -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; @@ -30,7 +31,7 @@ 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). @@ -38,11 +39,12 @@ class AdminNativePhpServer extends Server 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; /** @@ -52,6 +54,7 @@ class AdminNativePhpServer extends Server AdminCreateBlogPost::class, AdminUploadMedia::class, AdminUpdateBlogPost::class, + AdminPublishBlogPost::class, AdminGetBlogPost::class, AdminListBlogPosts::class, AdminListSignups::class, diff --git a/app/Mcp/Tools/Admin/AdminPublishBlogPost.php b/app/Mcp/Tools/Admin/AdminPublishBlogPost.php new file mode 100644 index 00000000..fe0ad488 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminPublishBlogPost.php @@ -0,0 +1,90 @@ +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 + */ + 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).'), + ]; + } +} diff --git a/tests/Feature/Mcp/AdminMcpOAuthTest.php b/tests/Feature/Mcp/AdminMcpOAuthTest.php index 07ebbfef..c2acfc36 100644 --- a/tests/Feature/Mcp/AdminMcpOAuthTest.php +++ b/tests/Feature/Mcp/AdminMcpOAuthTest.php @@ -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); @@ -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); + } }