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 @@ -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;

Expand All @@ -28,23 +29,25 @@ 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;

/**
* @var array<int, class-string<Tool>>
*/
protected array $tools = [
AdminCreateBlogPost::class,
AdminUpdateBlogPost::class,
AdminGetBlogPost::class,
AdminListBlogPosts::class,
AdminListSignups::class,
Expand Down
149 changes: 149 additions & 0 deletions app/Mcp/Tools/Admin/AdminUpdateBlogPost.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
<?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\Str;
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-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.')]
class AdminUpdateBlogPost 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'],
'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<string, Type>
*/
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.'),
];
}
}
150 changes: 150 additions & 0 deletions tests/Feature/Mcp/AdminMcpOAuthTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}