Skip to content

Commit c9391e7

Browse files
Merge pull request #514 from NativePHP/feature/admin-update-blog-post
Add admin-update-blog-post MCP tool
2 parents 0202dc0 + df10059 commit c9391e7

3 files changed

Lines changed: 308 additions & 6 deletions

File tree

‎app/Mcp/Servers/AdminNativePhpServer.php‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use App\Mcp\Tools\Admin\AdminSearchPlugins;
1515
use App\Mcp\Tools\Admin\AdminSearchSupportTickets;
1616
use App\Mcp\Tools\Admin\AdminSearchUsers;
17+
use App\Mcp\Tools\Admin\AdminUpdateBlogPost;
1718
use Laravel\Mcp\Server;
1819
use Laravel\Mcp\Server\Tool;
1920

@@ -28,23 +29,25 @@ class AdminNativePhpServer extends Server
2829
2930
Rules:
3031
- Never return passwords, remember tokens, GitHub tokens, license keys, Stripe secrets, or raw API credentials.
31-
- Blog: create unpublished drafts only in v1 (no publish tool).
32+
- Blog: create/update unpublished drafts in v1 (no publish tool; slug changes refused once published).
3233
- Other tools are read-only ops helpers (signups, users, companies, plugins, sales summaries, support).
3334
3435
Tools:
3536
1. admin-create-blog-post — create an unpublished article.
36-
2. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts.
37-
3. admin-list-signups / admin-search-users / admin-get-user — user support lookups.
38-
4. admin-list-companies / admin-get-company — email-domain company rollups.
39-
5. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets).
40-
6. admin-search-support-tickets / admin-get-support-ticket — support summaries.
37+
2. admin-update-blog-post — patch title/content/excerpt/slug (no publish/unpublish).
38+
3. admin-get-blog-post / admin-list-blog-posts — inspect drafts and published posts.
39+
4. admin-list-signups / admin-search-users / admin-get-user — user support lookups.
40+
5. admin-list-companies / admin-get-company — email-domain company rollups.
41+
6. admin-search-plugins / admin-sales-summary — marketplace/plugin ops (no secrets).
42+
7. admin-search-support-tickets / admin-get-support-ticket — support summaries.
4143
MARKDOWN;
4244

4345
/**
4446
* @var array<int, class-string<Tool>>
4547
*/
4648
protected array $tools = [
4749
AdminCreateBlogPost::class,
50+
AdminUpdateBlogPost::class,
4851
AdminGetBlogPost::class,
4952
AdminListBlogPosts::class,
5053
AdminListSignups::class,
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
<?php
2+
3+
namespace App\Mcp\Tools\Admin;
4+
5+
use App\Filament\Resources\ArticleResource;
6+
use App\Mcp\Tools\Concerns\RequiresAdmin;
7+
use App\Models\Article;
8+
use Illuminate\Contracts\JsonSchema\JsonSchema;
9+
use Illuminate\JsonSchema\Types\Type;
10+
use Illuminate\Support\Str;
11+
use Laravel\Mcp\Request;
12+
use Laravel\Mcp\Response;
13+
use Laravel\Mcp\Server\Attributes\Description;
14+
use Laravel\Mcp\Server\Attributes\Name;
15+
use Laravel\Mcp\Server\Tool;
16+
17+
#[Name('admin-update-blog-post')]
18+
#[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.')]
19+
class AdminUpdateBlogPost extends Tool
20+
{
21+
use RequiresAdmin;
22+
23+
public function handle(Request $request): Response
24+
{
25+
if ($denied = $this->ensureAdmin($request)) {
26+
return $denied;
27+
}
28+
29+
$validated = $request->validate([
30+
'id' => ['nullable', 'integer', 'min:1'],
31+
'slug' => ['nullable', 'string', 'max:255'],
32+
'title' => ['nullable', 'string', 'max:255'],
33+
'content' => ['nullable', 'string'],
34+
'excerpt' => ['nullable', 'string', 'max:5000'],
35+
]);
36+
37+
if (empty($validated['id']) && empty($validated['slug'])) {
38+
return Response::error('Provide id or slug.');
39+
}
40+
41+
$input = $request->all();
42+
$updatingTitle = array_key_exists('title', $input);
43+
$updatingContent = array_key_exists('content', $input);
44+
$updatingExcerpt = array_key_exists('excerpt', $input);
45+
// Slug is an update field only when identifying by id (same arg name as create).
46+
$updatingSlug = ! empty($validated['id']) && array_key_exists('slug', $input);
47+
48+
if (! $updatingTitle && ! $updatingContent && ! $updatingExcerpt && ! $updatingSlug) {
49+
return Response::error('Provide at least one field to update: title, content, excerpt, or slug.');
50+
}
51+
52+
$article = Article::query()
53+
->when(! empty($validated['id']), fn ($q) => $q->where('id', $validated['id']))
54+
->when(empty($validated['id']) && ! empty($validated['slug']), fn ($q) => $q->where('slug', $validated['slug']))
55+
->first();
56+
57+
if (! $article) {
58+
return Response::error('Article not found.');
59+
}
60+
61+
$updates = [];
62+
63+
if ($updatingTitle) {
64+
if (! filled($validated['title'] ?? null)) {
65+
return Response::error('title cannot be empty.');
66+
}
67+
$updates['title'] = $validated['title'];
68+
}
69+
70+
if ($updatingContent) {
71+
if (! filled($validated['content'] ?? null)) {
72+
return Response::error('content cannot be empty.');
73+
}
74+
$updates['content'] = $validated['content'];
75+
}
76+
77+
if ($updatingExcerpt) {
78+
$updates['excerpt'] = $validated['excerpt'] ?? '';
79+
}
80+
81+
if ($updatingSlug) {
82+
if ($article->isPublished()) {
83+
return Response::error('The slug cannot be changed after the article is published.');
84+
}
85+
86+
$slug = Str::slug((string) ($validated['slug'] ?? ''));
87+
88+
if ($slug === '') {
89+
return Response::error('slug cannot be empty.');
90+
}
91+
92+
if (! preg_match('/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $slug)) {
93+
return Response::error('slug must be a URL-safe kebab-case string.');
94+
}
95+
96+
$conflict = Article::query()
97+
->where('slug', $slug)
98+
->where('id', '!=', $article->id)
99+
->exists();
100+
101+
if ($conflict) {
102+
return Response::error('That slug is already taken.');
103+
}
104+
105+
$updates['slug'] = $slug;
106+
}
107+
108+
$article->fill($updates);
109+
$article->save();
110+
111+
$editUrl = null;
112+
113+
try {
114+
$editUrl = ArticleResource::getUrl('edit', ['record' => $article]);
115+
} catch (\Throwable) {
116+
$editUrl = url('/admin/articles/'.$article->id.'/edit');
117+
}
118+
119+
return Response::text($this->toJson([
120+
'id' => $article->id,
121+
'slug' => $article->slug,
122+
'title' => $article->title,
123+
'excerpt' => $article->excerpt,
124+
'content' => $article->content,
125+
'published' => $article->isPublished(),
126+
'published_at' => optional($article->published_at)?->toIso8601String(),
127+
'author_id' => $article->author_id,
128+
'admin_edit_url' => $editUrl,
129+
'preview_url' => route('article', $article),
130+
'preview_note' => $article->isPublished()
131+
? null
132+
: 'Drafts are only visible to signed-in site admins on the public blog route.',
133+
]));
134+
}
135+
136+
/**
137+
* @return array<string, Type>
138+
*/
139+
public function schema(JsonSchema $schema): array
140+
{
141+
return [
142+
'id' => $schema->integer()->description('Article id. Prefer id when changing the slug.'),
143+
'slug' => $schema->string()->description('Current slug to look up (when id omitted), or new slug to set (when id provided; drafts only).'),
144+
'title' => $schema->string()->description('Optional new title.'),
145+
'content' => $schema->string()->description('Optional new Markdown body.'),
146+
'excerpt' => $schema->string()->description('Optional new excerpt.'),
147+
];
148+
}
149+
}

‎tests/Feature/Mcp/AdminMcpOAuthTest.php‎

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public function test_admin_can_connect_via_oauth_and_use_admin_mcp_server(): voi
8383
$names = collect($tools->json('result.tools'))->pluck('name')->all();
8484

8585
$this->assertContains('admin-create-blog-post', $names);
86+
$this->assertContains('admin-update-blog-post', $names);
8687
$this->assertContains('admin-list-signups', $names);
8788
$this->assertContains('admin-list-companies', $names);
8889
$this->assertContains('admin-search-plugins', $names);
@@ -260,4 +261,153 @@ public function test_search_support_tickets_returns_summaries(): void
260261
$this->assertGreaterThanOrEqual(1, $payload['count']);
261262
$this->assertSame($ticket->mask, $payload['tickets'][0]['mask']);
262263
}
264+
265+
public function test_update_blog_post_patches_title_and_content_on_draft(): void
266+
{
267+
$article = Article::factory()->create([
268+
'author_id' => $this->admin->id,
269+
'slug' => 'draft-to-update',
270+
'title' => 'Old Title',
271+
'content' => 'Old content',
272+
'excerpt' => 'Old excerpt',
273+
'published_at' => null,
274+
]);
275+
276+
$tokens = $this->issueMcpOAuthTokens($this->admin);
277+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
278+
'id' => $article->id,
279+
'title' => 'Updated Title',
280+
'content' => "# Updated\n\nNew body.",
281+
])->assertOk();
282+
283+
$this->assertFalse($response->json('result.isError'));
284+
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);
285+
286+
$this->assertSame('Updated Title', $payload['title']);
287+
$this->assertSame("# Updated\n\nNew body.", $payload['content']);
288+
$this->assertSame('Old excerpt', $payload['excerpt']);
289+
$this->assertSame('draft-to-update', $payload['slug']);
290+
$this->assertFalse($payload['published']);
291+
$this->assertNull($payload['published_at']);
292+
293+
$article->refresh();
294+
$this->assertSame('Updated Title', $article->title);
295+
$this->assertSame("# Updated\n\nNew body.", $article->content);
296+
$this->assertNull($article->published_at);
297+
}
298+
299+
public function test_update_blog_post_by_slug_lookup(): void
300+
{
301+
$article = Article::factory()->create([
302+
'author_id' => $this->admin->id,
303+
'slug' => 'lookup-by-slug',
304+
'title' => 'Before',
305+
'published_at' => null,
306+
]);
307+
308+
$tokens = $this->issueMcpOAuthTokens($this->admin);
309+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
310+
'slug' => 'lookup-by-slug',
311+
'excerpt' => 'Patched excerpt only',
312+
])->assertOk();
313+
314+
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);
315+
$this->assertSame('Patched excerpt only', $payload['excerpt']);
316+
$this->assertSame('Before', $payload['title']);
317+
$this->assertSame($article->id, $payload['id']);
318+
}
319+
320+
public function test_update_blog_post_rejects_empty_update(): void
321+
{
322+
$article = Article::factory()->create([
323+
'author_id' => $this->admin->id,
324+
'published_at' => null,
325+
]);
326+
327+
$tokens = $this->issueMcpOAuthTokens($this->admin);
328+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
329+
'id' => $article->id,
330+
])->assertOk();
331+
332+
$this->assertTrue($response->json('result.isError'));
333+
$text = (string) data_get($response->json(), 'result.content.0.text');
334+
$this->assertStringContainsString('Provide at least one field to update', $text);
335+
}
336+
337+
public function test_update_blog_post_not_found(): void
338+
{
339+
$tokens = $this->issueMcpOAuthTokens($this->admin);
340+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
341+
'id' => 999999,
342+
'title' => 'Nope',
343+
])->assertOk();
344+
345+
$this->assertTrue($response->json('result.isError'));
346+
$text = (string) data_get($response->json(), 'result.content.0.text');
347+
$this->assertStringContainsString('Article not found', $text);
348+
}
349+
350+
public function test_update_blog_post_rejects_slug_change_when_published(): void
351+
{
352+
$article = Article::factory()->published()->create([
353+
'author_id' => $this->admin->id,
354+
'slug' => 'published-slug',
355+
]);
356+
357+
$tokens = $this->issueMcpOAuthTokens($this->admin);
358+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
359+
'id' => $article->id,
360+
'slug' => 'new-published-slug',
361+
])->assertOk();
362+
363+
$this->assertTrue($response->json('result.isError'));
364+
$text = (string) data_get($response->json(), 'result.content.0.text');
365+
$this->assertStringContainsString('cannot be changed after the article is published', $text);
366+
$this->assertSame('published-slug', $article->fresh()->slug);
367+
}
368+
369+
public function test_update_blog_post_rejects_slug_conflict(): void
370+
{
371+
Article::factory()->create([
372+
'author_id' => $this->admin->id,
373+
'slug' => 'taken-slug',
374+
'published_at' => null,
375+
]);
376+
$article = Article::factory()->create([
377+
'author_id' => $this->admin->id,
378+
'slug' => 'editable-slug',
379+
'published_at' => null,
380+
]);
381+
382+
$tokens = $this->issueMcpOAuthTokens($this->admin);
383+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
384+
'id' => $article->id,
385+
'slug' => 'taken-slug',
386+
])->assertOk();
387+
388+
$this->assertTrue($response->json('result.isError'));
389+
$text = (string) data_get($response->json(), 'result.content.0.text');
390+
$this->assertStringContainsString('already taken', $text);
391+
$this->assertSame('editable-slug', $article->fresh()->slug);
392+
}
393+
394+
public function test_update_blog_post_can_rename_draft_slug(): void
395+
{
396+
$article = Article::factory()->create([
397+
'author_id' => $this->admin->id,
398+
'slug' => 'old-draft-slug',
399+
'published_at' => null,
400+
]);
401+
402+
$tokens = $this->issueMcpOAuthTokens($this->admin);
403+
$response = $this->callMcpTool($tokens['access_token'], 'admin-update-blog-post', [
404+
'id' => $article->id,
405+
'slug' => 'new-draft-slug',
406+
])->assertOk();
407+
408+
$this->assertFalse($response->json('result.isError'));
409+
$payload = json_decode((string) data_get($response->json(), 'result.content.0.text'), true);
410+
$this->assertSame('new-draft-slug', $payload['slug']);
411+
$this->assertSame('new-draft-slug', $article->fresh()->slug);
412+
}
263413
}

0 commit comments

Comments
 (0)