Code-review surfaced two correctness bugs and a security gap that
needed to land before merging.
- UpdatePost::execute disabled every platform when called without
a `platforms` key. PublishPostTool relied on that path, so every
publish-via-MCP queued a job whose handler then found nothing
enabled to publish to. Wrap the platform toggle in
`Arr::has($data, 'platforms')` (matches the existing label_ids
guard a few lines up). Add a regression assertion to
`PostPublishToolTest::publish post immediate dispatches PublishPost
job` that the previously-enabled platform stays enabled.
- StorePostRequest declared rules for only `platforms`,
`scheduled_at`, and `status`. `validated()` then stripped
`content`, `media`, and `label_ids`, so REST `POST /api/posts`
silently created empty drafts. Added rules for content / media /
label_ids (with workspace-scoped `Rule::exists` for labels) and
dropped the unused `status` field — REST callers transition state
via `PUT /posts/{id}`. Removed the dead `platforms.*.content`
rule. Added a feature test that asserts content + media + labels
roundtrip on create, plus a regression that an `is_active=false`
social_account is rejected at validation.
- CreatePost::execute now syncs label_ids itself so REST and MCP
share the behavior. Removed the duplicate sync from CreatePostTool.
- MCP UpdatePostTool didn't scope `platforms.*.id` to the post being
updated, drifting from the REST UpdatePostRequest which adds
`Rule::exists('post_platforms','id')->where('post_id', ...)`. Now
it loads the post first (failing fast with `Post not found.` if
the workspace check rejects), then uses the same Rule::exists.
- MediaAttacher fetched any URL the caller passed, including
loopback / link-local / private targets — classic SSRF pivot.
Now `isPublicHttpUrl` rejects non-http(s) schemes, restricted IP
ranges, and DNS hostnames whose A/AAAA records resolve into those
ranges (covers DNS rebinding). Bypassed under
`app()->runningUnitTests()` so `Http::fake()` keeps working.
Streaming the response body lets us abort early once we exceed
MAX_BYTES instead of buffering the full payload first; redirects
are disabled so a 200→302 trick can't bypass the host check.
- The `media[]` JSON column had a lost-update race in
`attachFromUrls`: read `$post->media`, mutate in PHP, write back.
Two concurrent calls clobbered each other. Now wrapped in a
transaction with `lockForUpdate()`.
- ESLint: `resources/js/actions/**` and `resources/js/routes/**`
are auto-generated by Wayfinder on every build. Their import
order matches PHP scan order, not alphabetical, so import/order
fought eslint-fix forever. Added them to ignores.
65 lines
2.9 KiB
PHP
65 lines
2.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Mcp\Tools\Post;
|
|
|
|
use App\Actions\Post\CreatePost;
|
|
use App\Enums\PostPlatform\ContentType;
|
|
use App\Http\Resources\Api\PostResource;
|
|
use App\Rules\ContentTypeMatchesPlatform;
|
|
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
|
use Illuminate\Validation\Rule;
|
|
use Laravel\Mcp\Request;
|
|
use Laravel\Mcp\Response;
|
|
use Laravel\Mcp\ResponseFactory;
|
|
use Laravel\Mcp\Server\Attributes\Description;
|
|
use Laravel\Mcp\Server\Tool;
|
|
|
|
#[Description('Create a draft post in the current workspace. Accepts content, scheduled_at, label_ids, and a list of platforms (social accounts to publish on, with their content_type). Use list-content-types-tool to discover valid content_types per platform.')]
|
|
class CreatePostTool extends Tool
|
|
{
|
|
public function handle(Request $request): ResponseFactory
|
|
{
|
|
$workspace = $request->user()->currentWorkspace;
|
|
|
|
$validated = $request->validate([
|
|
'content' => ['nullable', 'string', 'max:63206'],
|
|
'scheduled_at' => ['nullable', 'date', 'after:now'],
|
|
'label_ids' => ['sometimes', 'array'],
|
|
'label_ids.*' => ['uuid', Rule::exists('workspace_labels', 'id')->where('workspace_id', $workspace->id)],
|
|
'platforms' => ['sometimes', 'array'],
|
|
'platforms.*.social_account_id' => [
|
|
'required',
|
|
'uuid',
|
|
Rule::exists('social_accounts', 'id')
|
|
->where('workspace_id', $workspace->id)
|
|
->where('is_active', true),
|
|
],
|
|
'platforms.*.content_type' => ['required', 'string', Rule::in(array_column(ContentType::cases(), 'value')), new ContentTypeMatchesPlatform],
|
|
]);
|
|
|
|
$post = CreatePost::execute($workspace, $request->user(), $validated);
|
|
|
|
$post->load(['postPlatforms.socialAccount', 'labels']);
|
|
|
|
return Response::structured((new PostResource($post))->resolve());
|
|
}
|
|
|
|
public function schema(JsonSchema $schema): array
|
|
{
|
|
return [
|
|
'content' => $schema->string()->description('The post caption/text body. Optional — can be edited later.'),
|
|
'scheduled_at' => $schema->string()->description('ISO 8601 datetime in the future (e.g. 2026-05-10T15:30:00Z). Defaults to today at 09:00 UTC.'),
|
|
'label_ids' => $schema->array()
|
|
->items($schema->string())
|
|
->description('Workspace label IDs to attach to the post.'),
|
|
'platforms' => $schema->array()
|
|
->items($schema->object(fn ($p) => [
|
|
'social_account_id' => $p->string()->required()->description('UUID of the connected social account.'),
|
|
'content_type' => $p->string()->required()->description('Format for this platform (e.g. linkedin_post, x_post, instagram_feed).'),
|
|
]))
|
|
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
|
|
];
|
|
}
|
|
}
|