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:
Paulo Castellano 2026-07-24 21:47:51 -03:00
parent b1cc1a6f1e
commit ff0bfa8a06
9 changed files with 265 additions and 2 deletions

View 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();
}
}

View file

@ -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),
]);
}
}

View file

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

View file

@ -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.'),
];

View file

@ -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.'),
];

View 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.'),
];
}
}

View file

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

View 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();
});

View 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();
});