Expose Pinterest board IDs via MCP and REST API.
Agents need board_id to publish pins; list boards per connected account so create/update can set platforms[].meta.board_id. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
b1cc1a6f1e
commit
ff0bfa8a06
9 changed files with 265 additions and 2 deletions
29
app/Actions/SocialAccount/ListPinterestBoards.php
Normal file
29
app/Actions/SocialAccount/ListPinterestBoards.php
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Actions\SocialAccount;
|
||||
|
||||
use App\Models\SocialAccount;
|
||||
use App\Services\Social\PinterestPublisher;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class ListPinterestBoards
|
||||
{
|
||||
/**
|
||||
* @return list<array{id: string, name: string}>
|
||||
*/
|
||||
public static function execute(SocialAccount $account): array
|
||||
{
|
||||
$boards = app(PinterestPublisher::class)->getBoards($account);
|
||||
|
||||
return Collection::make($boards)
|
||||
->map(fn (mixed $board): array => [
|
||||
'id' => (string) data_get($board, 'id'),
|
||||
'name' => (string) data_get($board, 'name'),
|
||||
])
|
||||
->filter(fn (array $board): bool => $board['id'] !== '')
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Actions\SocialAccount\ListPinterestBoards;
|
||||
use App\Actions\SocialAccount\ToggleSocialAccount;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Http\Resources\Api\SocialAccountResource;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
|
@ -34,4 +36,25 @@ public function toggle(Request $request, SocialAccount $account): SocialAccountR
|
|||
|
||||
return new SocialAccountResource($account);
|
||||
}
|
||||
|
||||
public function boards(Request $request, SocialAccount $account): JsonResponse
|
||||
{
|
||||
if ($account->workspace_id !== $request->user()->currentWorkspace->id) {
|
||||
return response()->json(
|
||||
['message' => 'Account not found.'],
|
||||
Response::HTTP_NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
if ($account->platform !== Platform::Pinterest) {
|
||||
return response()->json(
|
||||
['message' => 'Boards are only available for Pinterest accounts.'],
|
||||
Response::HTTP_UNPROCESSABLE_ENTITY,
|
||||
);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'boards' => ListPinterestBoards::execute($account),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@
|
|||
use App\Mcp\Tools\Signature\DeleteSignatureTool;
|
||||
use App\Mcp\Tools\Signature\ListSignaturesTool;
|
||||
use App\Mcp\Tools\Signature\UpdateSignatureTool;
|
||||
use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool;
|
||||
use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool;
|
||||
use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool;
|
||||
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
|
||||
|
|
@ -73,6 +74,7 @@ class TryPostServer extends Server
|
|||
|
||||
// Social Accounts
|
||||
ListSocialAccountsTool::class,
|
||||
ListPinterestBoardsTool::class,
|
||||
ToggleSocialAccountTool::class,
|
||||
|
||||
// Workspace
|
||||
|
|
|
|||
|
|
@ -63,7 +63,7 @@ public function schema(JsonSchema $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).'),
|
||||
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
|
||||
'meta' => $p->object()->description('Per-platform metadata. Instagram/Facebook: aspect_ratio (1:1|4:5|16:9|original). TikTok: privacy_level (required to publish) + flags (allow_comments, allow_duet, allow_stitch, disclose, brand_content_toggle, brand_organic_toggle, is_aigc, auto_add_music). Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions ([{token,label}]), embeds ([{title,description,url,image,color}]).'),
|
||||
]))
|
||||
->description('Platforms to publish on. Accounts not listed remain available but disabled.'),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ public function schema(JsonSchema $schema): array
|
|||
->items($schema->object(fn ($p) => [
|
||||
'id' => $p->string()->required()->description('UUID of the post_platform row (from get-post-tool / list-posts-tool).'),
|
||||
'content_type' => $p->string()->description('New content_type for this platform.'),
|
||||
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'),
|
||||
'meta' => $p->object()->description('Per-platform metadata override. Instagram/Facebook: aspect_ratio. TikTok: privacy_level (required to publish) + flags. Pinterest: board_id (required to publish — call ListPinterestBoardsTool first). Discord: channel_id (required to publish), mentions, embeds. Merged with existing meta.'),
|
||||
]))
|
||||
->description('Platforms to enable for publishing. Any platform NOT listed will be disabled. Pass an empty array to disable all.'),
|
||||
];
|
||||
|
|
|
|||
48
app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
Normal file
48
app/Mcp/Tools/SocialAccount/ListPinterestBoardsTool.php
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Mcp\Tools\SocialAccount;
|
||||
|
||||
use App\Actions\SocialAccount\ListPinterestBoards;
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\SocialAccount;
|
||||
use Illuminate\Contracts\JsonSchema\JsonSchema;
|
||||
use Laravel\Mcp\Request;
|
||||
use Laravel\Mcp\Response;
|
||||
use Laravel\Mcp\ResponseFactory;
|
||||
use Laravel\Mcp\Server\Attributes\Description;
|
||||
use Laravel\Mcp\Server\Tool;
|
||||
|
||||
#[Description('List Pinterest boards for a connected Pinterest account. Use the returned board id as platforms[].meta.board_id when creating or updating a Pinterest post (required to publish).')]
|
||||
class ListPinterestBoardsTool extends Tool
|
||||
{
|
||||
public function handle(Request $request): Response|ResponseFactory
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'account_id' => ['required', 'string', 'uuid'],
|
||||
]);
|
||||
|
||||
$account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id)
|
||||
->find(data_get($validated, 'account_id'));
|
||||
|
||||
if (! $account) {
|
||||
return Response::error('Social account not found.');
|
||||
}
|
||||
|
||||
if ($account->platform !== Platform::Pinterest) {
|
||||
return Response::error('This tool only works with Pinterest social accounts.');
|
||||
}
|
||||
|
||||
return Response::structured([
|
||||
'boards' => ListPinterestBoards::execute($account),
|
||||
]);
|
||||
}
|
||||
|
||||
public function schema(JsonSchema $schema): array
|
||||
{
|
||||
return [
|
||||
'account_id' => $schema->string()->required()->description('The UUID of the connected Pinterest social account.'),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -50,6 +50,9 @@
|
|||
// Social Accounts
|
||||
Route::get('/social-accounts', [SocialAccountController::class, 'index'])->name('api.social-accounts.index');
|
||||
Route::put('/social-accounts/{account}/toggle', [SocialAccountController::class, 'toggle'])->name('api.social-accounts.toggle');
|
||||
Route::get('/social-accounts/{account}/boards', [SocialAccountController::class, 'boards'])
|
||||
->middleware('throttle:60,1')
|
||||
->name('api.social-accounts.boards');
|
||||
|
||||
// API Keys
|
||||
Route::get('/api-keys', [ApiKeyController::class, 'index'])->name('api.api-keys.index');
|
||||
|
|
|
|||
79
tests/Feature/Api/PinterestBoardsApiTest.php
Normal file
79
tests/Feature/Api/PinterestBoardsApiTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
|
||||
beforeEach(function () {
|
||||
$result = createApiTestToken();
|
||||
$this->user = $result['user'];
|
||||
$this->workspace = $result['workspace'];
|
||||
$this->plainToken = $result['plain_token'];
|
||||
});
|
||||
|
||||
it('lists pinterest boards for a connected account', function () {
|
||||
$account = SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'access_token' => 'pinterest-token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.pinterest.api').'/boards*' => Http::response([
|
||||
'items' => [
|
||||
['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'],
|
||||
['id' => 'board_2', 'name' => 'Product', 'privacy' => 'PUBLIC'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = $this->getJson(route('api.social-accounts.boards', $account), [
|
||||
'Authorization' => "Bearer {$this->plainToken}",
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$response->assertExactJson([
|
||||
'boards' => [
|
||||
['id' => 'board_1', 'name' => 'Ideas'],
|
||||
['id' => 'board_2', 'name' => 'Product'],
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects boards listing for non-pinterest accounts', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
]);
|
||||
|
||||
$response = $this->getJson(route('api.social-accounts.boards', $account), [
|
||||
'Authorization' => "Bearer {$this->plainToken}",
|
||||
]);
|
||||
|
||||
$response->assertUnprocessable();
|
||||
$response->assertJsonPath('message', 'Boards are only available for Pinterest accounts.');
|
||||
});
|
||||
|
||||
it('cannot list boards for an account from another workspace', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$response = $this->getJson(route('api.social-accounts.boards', $account), [
|
||||
'Authorization' => "Bearer {$this->plainToken}",
|
||||
]);
|
||||
|
||||
$response->assertNotFound();
|
||||
});
|
||||
|
||||
it('requires authentication to list boards', function () {
|
||||
$account = SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
]);
|
||||
|
||||
$this->getJson(route('api.social-accounts.boards', $account))
|
||||
->assertUnauthorized();
|
||||
});
|
||||
79
tests/Feature/Mcp/ListPinterestBoardsToolTest.php
Normal file
79
tests/Feature/Mcp/ListPinterestBoardsToolTest.php
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use App\Enums\SocialAccount\Platform;
|
||||
use App\Enums\UserWorkspace\Role;
|
||||
use App\Mcp\Servers\TryPostServer;
|
||||
use App\Mcp\Tools\SocialAccount\ListPinterestBoardsTool;
|
||||
use App\Models\SocialAccount;
|
||||
use App\Models\User;
|
||||
use App\Models\Workspace;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Testing\Fluent\AssertableJson;
|
||||
|
||||
beforeEach(function () {
|
||||
$this->user = User::factory()->create();
|
||||
$this->workspace = Workspace::factory()->create(['user_id' => $this->user->id]);
|
||||
$this->workspace->members()->attach($this->user->id, ['role' => Role::Member->value]);
|
||||
$this->user->update(['current_workspace_id' => $this->workspace->id]);
|
||||
});
|
||||
|
||||
test('lists pinterest boards as id and name', function () {
|
||||
$account = SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'access_token' => 'pinterest-token',
|
||||
]);
|
||||
|
||||
Http::fake([
|
||||
config('trypost.platforms.pinterest.api').'/boards*' => Http::response([
|
||||
'items' => [
|
||||
['id' => 'board_1', 'name' => 'Ideas', 'privacy' => 'PUBLIC'],
|
||||
['id' => 'board_2', 'name' => 'Product'],
|
||||
],
|
||||
], 200),
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]);
|
||||
|
||||
$response->assertOk()
|
||||
->assertStructuredContent(function (AssertableJson $json) {
|
||||
$json->has('boards', 2)
|
||||
->where('boards.0.id', 'board_1')
|
||||
->where('boards.0.name', 'Ideas')
|
||||
->where('boards.1.id', 'board_2')
|
||||
->where('boards.1.name', 'Product');
|
||||
});
|
||||
});
|
||||
|
||||
test('rejects non-pinterest accounts', function () {
|
||||
$account = SocialAccount::factory()->create([
|
||||
'workspace_id' => $this->workspace->id,
|
||||
'platform' => Platform::LinkedIn,
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]);
|
||||
|
||||
$response->assertHasErrors(['This tool only works with Pinterest social accounts.']);
|
||||
});
|
||||
|
||||
test('cannot list boards for another workspace account', function () {
|
||||
$otherWorkspace = Workspace::factory()->create();
|
||||
$account = SocialAccount::factory()->pinterest()->create([
|
||||
'workspace_id' => $otherWorkspace->id,
|
||||
]);
|
||||
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListPinterestBoardsTool::class, ['account_id' => $account->id]);
|
||||
|
||||
$response->assertHasErrors(['Social account not found.']);
|
||||
});
|
||||
|
||||
test('validates account_id is required', function () {
|
||||
$response = TryPostServer::actingAs($this->user)
|
||||
->tool(ListPinterestBoardsTool::class, []);
|
||||
|
||||
$response->assertHasErrors();
|
||||
});
|
||||
Loading…
Reference in a new issue