From df1005906116b91634c7e4dbb95d9ddc172e1d4f Mon Sep 17 00:00:00 2001 From: Shane Rosenthal Date: Fri, 11 Sep 2026 19:55:06 -0400 Subject: [PATCH] Add admin-update-blog-post MCP tool for content patches. Lets Admin MCP update draft/published article title, content, and excerpt without publishing. Slug changes work on drafts only and match Filament's published-slug lock. --- app/Mcp/Servers/AdminNativePhpServer.php | 15 +- app/Mcp/Tools/Admin/AdminUpdateBlogPost.php | 149 +++++++++++++++++++ tests/Feature/Mcp/AdminMcpOAuthTest.php | 150 ++++++++++++++++++++ 3 files changed, 308 insertions(+), 6 deletions(-) create mode 100644 app/Mcp/Tools/Admin/AdminUpdateBlogPost.php diff --git a/app/Mcp/Servers/AdminNativePhpServer.php b/app/Mcp/Servers/AdminNativePhpServer.php index 0aa0a7f2..44d98760 100644 --- a/app/Mcp/Servers/AdminNativePhpServer.php +++ b/app/Mcp/Servers/AdminNativePhpServer.php @@ -14,6 +14,7 @@ use App\Mcp\Tools\Admin\AdminSearchPlugins; use App\Mcp\Tools\Admin\AdminSearchSupportTickets; use App\Mcp\Tools\Admin\AdminSearchUsers; +use App\Mcp\Tools\Admin\AdminUpdateBlogPost; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Tool; @@ -28,16 +29,17 @@ class AdminNativePhpServer extends Server Rules: - Never return passwords, remember tokens, GitHub tokens, license keys, Stripe secrets, or raw API credentials. - - Blog: create unpublished drafts only in v1 (no publish tool). + - Blog: create/update unpublished drafts in v1 (no publish tool; slug changes refused once published). - 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-get-blog-post / admin-list-blog-posts — inspect drafts and published posts. - 3. admin-list-signups / admin-search-users / admin-get-user — user support lookups. - 4. admin-list-companies / admin-get-company — email-domain company rollups. - 5. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets). - 6. admin-search-support-tickets / admin-get-support-ticket — support summaries. + 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. MARKDOWN; /** @@ -45,6 +47,7 @@ class AdminNativePhpServer extends Server */ protected array $tools = [ AdminCreateBlogPost::class, + AdminUpdateBlogPost::class, AdminGetBlogPost::class, AdminListBlogPosts::class, AdminListSignups::class, diff --git a/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php b/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php new file mode 100644 index 00000000..c7e6ce21 --- /dev/null +++ b/app/Mcp/Tools/Admin/AdminUpdateBlogPost.php @@ -0,0 +1,149 @@ +ensureAdmin($request)) { + return $denied; + } + + $validated = $request->validate([ + 'id' => ['nullable', 'integer', 'min:1'], + 'slug' => ['nullable', 'string', 'max:255'], + 'title' => ['nullable', 'string', 'max:255'], + 'content' => ['nullable', 'string'], + 'excerpt' => ['nullable', 'string', 'max:5000'], + ]); + + if (empty($validated['id']) && empty($validated['slug'])) { + return Response::error('Provide id or slug.'); + } + + $input = $request->all(); + $updatingTitle = array_key_exists('title', $input); + $updatingContent = array_key_exists('content', $input); + $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); + + if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug) { + return Response::error('Provide at least one field to update: title, content, excerpt, 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.'); + } + + $updates = []; + + if ($updatingTitle) { + if (! filled($validated['title'] ?? null)) { + return Response::error('title cannot be empty.'); + } + $updates['title'] = $validated['title']; + } + + if ($updatingContent) { + if (! filled($validated['content'] ?? null)) { + return Response::error('content cannot be empty.'); + } + $updates['content'] = $validated['content']; + } + + if ($updatingExcerpt) { + $updates['excerpt'] = $validated['excerpt'] ?? ''; + } + + if ($updatingSlug) { + if ($article->isPublished()) { + return Response::error('The slug cannot be changed after the article is published.'); + } + + $slug = Str::slug((string) ($validated['slug'] ?? '')); + + if ($slug === '') { + return Response::error('slug cannot be empty.'); + } + + if (! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) { + return Response::error('slug must be a URL-safe kebab-case string.'); + } + + $conflict = Article::query() + ->where('slug', $slug) + ->where('id', '!=', $article->id) + ->exists(); + + if ($conflict) { + return Response::error('That slug is already taken.'); + } + + $updates['slug'] = $slug; + } + + $article->fill($updates); + $article->save(); + + $editUrl = null; + + try { + $editUrl = ArticleResource::getUrl('edit', ['record' => $article]); + } catch (\Throwable) { + $editUrl = url('/admin/articles/'.$article->id.'/edit'); + } + + return Response::text($this->toJson([ + 'id' => $article->id, + 'slug' => $article->slug, + 'title' => $article->title, + 'excerpt' => $article->excerpt, + 'content' => $article->content, + 'published' => $article->isPublished(), + 'published_at' => optional($article->published_at)?->toIso8601String(), + 'author_id' => $article->author_id, + 'admin_edit_url' => $editUrl, + 'preview_url' => route('article', $article), + 'preview_note' => $article->isPublished() + ? null + : 'Drafts are only visible to signed-in site admins on the public blog route.', + ])); + } + + /** + * @return array + */ + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('Article id. Prefer id when changing the slug.'), + 'slug' => $schema->string()->description('Current slug to look up (when id omitted), or new slug to set (when id provided; drafts only).'), + 'title' => $schema->string()->description('Optional new title.'), + 'content' => $schema->string()->description('Optional new Markdown body.'), + 'excerpt' => $schema->string()->description('Optional new excerpt.'), + ]; + } +} diff --git a/tests/Feature/Mcp/AdminMcpOAuthTest.php b/tests/Feature/Mcp/AdminMcpOAuthTest.php index be93de50..dfd940b3 100644 --- a/tests/Feature/Mcp/AdminMcpOAuthTest.php +++ b/tests/Feature/Mcp/AdminMcpOAuthTest.php @@ -83,6 +83,7 @@ public function test_admin_can_connect_via_oauth_and_use_admin_mcp_server(): voi $names = collect($tools->json('result.tools'))->pluck('name')->all(); $this->assertContains('admin-create-blog-post', $names); + $this->assertContains('admin-update-blog-post', $names); $this->assertContains('admin-list-signups', $names); $this->assertContains('admin-list-companies', $names); $this->assertContains('admin-search-plugins', $names); @@ -260,4 +261,153 @@ public function test_search_support_tickets_returns_summaries(): void $this->assertGreaterThanOrEqual(1, $payload['count']); $this->assertSame($ticket->mask, $payload['tickets'][0]['mask']); } + + public function test_update_blog_post_patches_title_and_content_on_draft(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'draft-to-update', + 'title' => 'Old Title', + 'content' => 'Old content', + 'excerpt' => 'Old excerpt', + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'title' => 'Updated Title', + 'content' => "# Updated\n\nNew body.", + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + + $this->assertSame('Updated Title', $payload['title']); + $this->assertSame("# Updated\n\nNew body.", $payload['content']); + $this->assertSame('Old excerpt', $payload['excerpt']); + $this->assertSame('draft-to-update', $payload['slug']); + $this->assertFalse($payload['published']); + $this->assertNull($payload['published_at']); + + $article->refresh(); + $this->assertSame('Updated Title', $article->title); + $this->assertSame("# Updated\n\nNew body.", $article->content); + $this->assertNull($article->published_at); + } + + public function test_update_blog_post_by_slug_lookup(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'lookup-by-slug', + 'title' => 'Before', + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'slug' => 'lookup-by-slug', + 'excerpt' => 'Patched excerpt only', + ])->assertOk(); + + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + $this->assertSame('Patched excerpt only', $payload['excerpt']); + $this->assertSame('Before', $payload['title']); + $this->assertSame($article->id, $payload['id']); + } + + public function test_update_blog_post_rejects_empty_update(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $text = (string) data_get($response->json(), 'result.content.0.text'); + $this->assertStringContainsString('Provide at least one field to update', $text); + } + + public function test_update_blog_post_not_found(): void + { + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => 999999, + 'title' => 'Nope', + ])->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_update_blog_post_rejects_slug_change_when_published(): void + { + $article = Article::factory()->published()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'published-slug', + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'slug' => 'new-published-slug', + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $text = (string) data_get($response->json(), 'result.content.0.text'); + $this->assertStringContainsString('cannot be changed after the article is published', $text); + $this->assertSame('published-slug', $article->fresh()->slug); + } + + public function test_update_blog_post_rejects_slug_conflict(): void + { + Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'taken-slug', + 'published_at' => null, + ]); + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'editable-slug', + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'slug' => 'taken-slug', + ])->assertOk(); + + $this->assertTrue($response->json('result.isError')); + $text = (string) data_get($response->json(), 'result.content.0.text'); + $this->assertStringContainsString('already taken', $text); + $this->assertSame('editable-slug', $article->fresh()->slug); + } + + public function test_update_blog_post_can_rename_draft_slug(): void + { + $article = Article::factory()->create([ + 'author_id' => $this->admin->id, + 'slug' => 'old-draft-slug', + 'published_at' => null, + ]); + + $tokens = $this->issueMcpOAuthTokens($this->admin); + $response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [ + 'id' => $article->id, + 'slug' => 'new-draft-slug', + ])->assertOk(); + + $this->assertFalse($response->json('result.isError')); + $payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true); + $this->assertSame('new-draft-slug', $payload['slug']); + $this->assertSame('new-draft-slug', $article->fresh()->slug); + } }