refactor: standardize MCP tool responses using API resources and enforce strict structural validation in feature tests

This commit is contained in:
Paulo Castellano 2026-05-03 21:34:20 -03:00
parent 2575720be7
commit 137cfb4f6e
32 changed files with 430 additions and 156 deletions

View file

@ -16,9 +16,9 @@ public function toArray(Request $request): array
{
return [
'id' => $this->id,
'platform' => $this->platform,
'content_type' => $this->content_type,
'status' => $this->status,
'platform' => $this->platform?->value,
'content_type' => $this->content_type?->value,
'status' => $this->status?->value,
'enabled' => $this->enabled,
'social_account' => new SocialAccountResource($this->whenLoaded('socialAccount')),
];

View file

@ -18,7 +18,7 @@ public function toArray(Request $request): array
'id' => $this->id,
'content' => $this->content,
'media' => $this->media,
'status' => $this->status,
'status' => $this->status?->value,
'scheduled_at' => $this->scheduled_at?->format('Y-m-d H:i:s'),
'published_at' => $this->published_at?->format('Y-m-d H:i:s'),
'platforms' => PostPlatformResource::collection($this->whenLoaded('postPlatforms')),

View file

@ -16,11 +16,11 @@ public function toArray(Request $request): array
{
return [
'id' => $this->id,
'platform' => $this->platform,
'platform' => $this->platform?->value,
'display_name' => $this->display_name,
'username' => $this->username,
'is_active' => $this->is_active,
'status' => $this->status,
'status' => $this->status?->value,
];
}
}

View file

@ -29,7 +29,7 @@
#[Name('TryPost')]
#[Version('1.0.0')]
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, workspaces, and API keys.')]
#[Instructions('TryPost is a social media scheduling platform. Use this server to manage posts, signatures, labels, social accounts, workspaces, and API keys.')]
class TryPostServer extends Server
{
public int $defaultPaginationLength = 100;

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\ApiKey;
use App\Http\Resources\Api\ApiKeyResource;
use App\Models\AccessToken;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@ -12,7 +13,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new API key. Returns the plain text token which is only shown once.')]
#[Description('Create a new Personal Access Token (API key) for the current workspace. The plain token value is returned ONCE — store it immediately, it cannot be retrieved later.')]
class CreateApiKeyTool extends Tool
{
public function handle(Request $request): ResponseFactory
@ -23,30 +24,26 @@ public function handle(Request $request): ResponseFactory
]);
$user = $request->user();
$workspace = $user->currentWorkspace;
$result = $user->createToken($validated['name']);
$result = $user->createToken(data_get($validated, 'name'));
$token = AccessToken::find($result->token->id);
$token->forceFill([
'workspace_id' => $workspace->id,
'expires_at' => $validated['expires_at'] ?? null,
'workspace_id' => $user->current_workspace_id,
'expires_at' => data_get($validated, 'expires_at'),
])->saveQuietly();
return Response::structured([
'id' => $token->id,
'name' => $token->name,
'workspace_id' => $token->workspace_id,
'expires_at' => $token->expires_at?->toIso8601String(),
'token' => $result->accessToken,
]);
return Response::structured(array_merge(
(new ApiKeyResource($token))->resolve(),
['token' => $result->accessToken],
));
}
public function schema(JsonSchema $schema): array
{
return [
'name' => $schema->string()->required()->description('The API key name.'),
'expires_at' => $schema->string()->description('Optional expiration date.'),
'name' => $schema->string()->required()->description('A human-readable name to identify the key (e.g. "My integration").'),
'expires_at' => $schema->string()->description('Optional ISO 8601 expiration date (e.g. 2026-12-31). Must be in the future.'),
];
}
}

View file

@ -11,18 +11,23 @@
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Description('Revoke an API key by ID.')]
#[IsDestructive]
#[Description('Revoke (delete) a Personal Access Token by ID. The current OAuth session token cannot be revoked through this tool. Existing integrations using the token will stop working.')]
class DeleteApiKeyTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate(['api_key_id' => ['required', 'string']]);
// workspace_id filter excludes OAuth-flow tokens (which have null
// workspace_id), so the caller can't accidentally revoke their own
// ChatGPT/MCP session token through this tool.
$token = AccessToken::where('user_id', $request->user()->id)
->where('workspace_id', $request->user()->currentWorkspace->id)
->where('workspace_id', $request->user()->current_workspace_id)
->where('revoked', false)
->find($validated['api_key_id']);
->find(data_get($validated, 'api_key_id'));
if (! $token) {
return Response::error('API key not found.');
@ -36,7 +41,7 @@ public function handle(Request $request): Response|ResponseFactory
public function schema(JsonSchema $schema): array
{
return [
'api_key_id' => $schema->string()->required()->description('The API key ID to delete.'),
'api_key_id' => $schema->string()->required()->description('The API key ID to revoke.'),
];
}
}

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\ApiKey;
use App\Http\Resources\Api\ApiKeyResource;
use App\Models\AccessToken;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -13,17 +14,22 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all API keys for the current workspace.')]
#[Description('List all Personal Access Tokens (API keys) for the current workspace. Returns metadata only — the secret token value is shown only once at creation. OAuth tokens (e.g. ChatGPT MCP sessions) are excluded.')]
class ListApiKeysTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
// Filtering by workspace_id excludes OAuth-flow tokens (whose
// workspace_id is null and resolved at request time via
// LoadWorkspaceFromToken middleware).
$tokens = AccessToken::where('user_id', $request->user()->id)
->where('workspace_id', $request->user()->currentWorkspace->id)
->where('workspace_id', $request->user()->current_workspace_id)
->where('revoked', false)
->latest()
->get(['id', 'name', 'expires_at', 'last_used_at', 'created_at']);
->get();
return Response::structured($tokens->toArray());
return Response::structured([
'api_keys' => ApiKeyResource::collection($tokens)->resolve(),
]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Label;
use App\Actions\Label\CreateLabel;
use App\Http\Resources\Api\LabelResource;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -24,7 +25,7 @@ public function handle(Request $request): ResponseFactory
$label = CreateLabel::execute($request->user()->currentWorkspace, $validated);
return Response::structured($label->toArray());
return Response::structured((new LabelResource($label))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -12,14 +12,18 @@
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Description('Delete a label by ID.')]
#[IsDestructive]
#[Description('Delete a label permanently. The label is detached from all posts that referenced it. This cannot be undone.')]
class DeleteLabelTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate(['label_id' => ['required', 'string']]);
$label = WorkspaceLabel::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($request->validate(['label_id' => ['required', 'string']]), 'label_id'));
->find(data_get($validated, 'label_id'));
if (! $label) {
return Response::error('Label not found.');

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Label;
use App\Http\Resources\Api\LabelResource;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -12,13 +13,18 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all labels for the current workspace.')]
#[Description('List all labels for the current workspace. Labels are colored tags used to categorize posts.')]
class ListLabelsTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$labels = $request->user()->currentWorkspace->labels()->latest()->get();
$labels = $request->user()->currentWorkspace
->labels()
->latest()
->get();
return Response::structured($labels->toArray());
return Response::structured([
'labels' => LabelResource::collection($labels)->resolve(),
]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Label;
use App\Actions\Label\UpdateLabel;
use App\Http\Resources\Api\LabelResource;
use App\Models\WorkspaceLabel;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@ -33,7 +34,7 @@ public function handle(Request $request): Response|ResponseFactory
$label = UpdateLabel::execute($label, $validated);
return Response::structured($label->toArray());
return Response::structured((new LabelResource($label))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Post;
use App\Actions\Post\CreatePost;
use App\Http\Resources\Api\PostResource;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -12,23 +13,32 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Create a new draft post in the current workspace. A post platform entry is created for each connected social account.')]
#[Description('Create a new draft post in the current workspace. The post is automatically attached to every active social account in the workspace (one PostPlatform per account).')]
class CreatePostTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$workspace = $request->user()->currentWorkspace;
$validated = $request->validate(['date' => ['nullable', 'string']]);
$post = CreatePost::execute($workspace, $request->user(), $validated);
$post->load(['postPlatforms.socialAccount']);
$validated = $request->validate([
'content' => ['nullable', 'string'],
'date' => ['nullable', 'date_format:Y-m-d'],
]);
return Response::structured($post->toArray());
$post = CreatePost::execute(
$request->user()->currentWorkspace,
$request->user(),
$validated,
);
$post->load(['postPlatforms.socialAccount', 'labels']);
return Response::structured((new PostResource($post))->resolve());
}
public function schema(JsonSchema $schema): array
{
return [
'date' => $schema->string()->description('The scheduled date (Y-m-d). Defaults to today.'),
'content' => $schema->string()->description('The post caption/text content. Optional — can be edited later.'),
'date' => $schema->string()->description('Scheduled date as Y-m-d (e.g. 2026-05-10). Defaults to today. Time is fixed at 09:00 UTC; edit the post later for a specific time.'),
];
}
}

View file

@ -12,14 +12,18 @@
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Description('Delete a post by ID.')]
#[IsDestructive]
#[Description('Delete a post permanently. This cannot be undone.')]
class DeletePostTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate(['post_id' => ['required', 'string']]);
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($request->validate(['post_id' => ['required', 'string']]), 'post_id'));
->find(data_get($validated, 'post_id'));
if (! $post) {
return Response::error('Post not found.');

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Post;
use App\Http\Resources\Api\PostResource;
use App\Models\Post;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@ -19,15 +20,17 @@ class GetPostTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate(['post_id' => ['required', 'string']]);
$post = Post::where('workspace_id', $request->user()->current_workspace_id)
->with(['postPlatforms.socialAccount', 'labels'])
->find(data_get($request->validate(['post_id' => ['required', 'string']]), 'post_id'));
->find(data_get($validated, 'post_id'));
if (! $post) {
return Response::error('Post not found.');
}
return Response::structured($post->toArray());
return Response::structured((new PostResource($post))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Post;
use App\Http\Resources\Api\PostResource;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -12,7 +13,7 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all posts for the current workspace. Returns posts with their platforms, status, and scheduled date.')]
#[Description('List posts for the current workspace, ordered by scheduled date (newest first).')]
class ListPostsTool extends Tool
{
public function handle(Request $request): ResponseFactory
@ -21,8 +22,11 @@ public function handle(Request $request): ResponseFactory
->posts()
->with(['postPlatforms.socialAccount', 'labels'])
->latest('scheduled_at')
->paginate(50);
->limit(50)
->get();
return Response::structured($posts->toArray());
return Response::structured([
'posts' => PostResource::collection($posts)->resolve(),
]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Signature;
use App\Actions\Signature\CreateSignature;
use App\Http\Resources\Api\SignatureResource;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
@ -24,7 +25,7 @@ public function handle(Request $request): ResponseFactory
$signature = CreateSignature::execute($request->user()->currentWorkspace, $validated);
return Response::structured($signature->toArray());
return Response::structured((new SignatureResource($signature))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -12,14 +12,18 @@
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsDestructive;
#[Description('Delete a signature by ID.')]
#[IsDestructive]
#[Description('Delete a signature permanently. This cannot be undone.')]
class DeleteSignatureTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
{
$validated = $request->validate(['signature_id' => ['required', 'string']]);
$signature = WorkspaceSignature::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($request->validate(['signature_id' => ['required', 'string']]), 'signature_id'));
->find(data_get($validated, 'signature_id'));
if (! $signature) {
return Response::error('Signature not found.');

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Signature;
use App\Http\Resources\Api\SignatureResource;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -12,13 +13,18 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all signatures for the current workspace.')]
#[Description('List all signatures for the current workspace. Signatures are reusable text blocks (hashtags, links, custom text) that can be appended to posts.')]
class ListSignaturesTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
$signatures = $request->user()->currentWorkspace->signatures()->latest()->get();
$signatures = $request->user()->currentWorkspace
->signatures()
->latest()
->get();
return Response::structured($signatures->toArray());
return Response::structured([
'signatures' => SignatureResource::collection($signatures)->resolve(),
]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\Signature;
use App\Actions\Signature\UpdateSignature;
use App\Http\Resources\Api\SignatureResource;
use App\Models\WorkspaceSignature;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@ -33,7 +34,7 @@ public function handle(Request $request): Response|ResponseFactory
$signature = UpdateSignature::execute($signature, $validated);
return Response::structured($signature->toArray());
return Response::structured((new SignatureResource($signature))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\SocialAccount;
use App\Http\Resources\Api\SocialAccountResource;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -12,7 +13,7 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('List all connected social accounts for the current workspace.')]
#[Description('List all connected social accounts for the current workspace (LinkedIn, X, Bluesky, Pinterest, Threads, etc.). Each account has an id, platform, display_name, username, is_active flag, and connection status.')]
class ListSocialAccountsTool extends Tool
{
public function handle(Request $request): ResponseFactory
@ -20,16 +21,10 @@ public function handle(Request $request): ResponseFactory
$accounts = $request->user()->currentWorkspace
->socialAccounts()
->orderBy('platform')
->get()
->map(fn ($account) => [
'id' => $account->id,
'platform' => $account->platform->value,
'display_name' => $account->display_name,
'username' => $account->username,
'is_active' => $account->is_active,
'status' => $account->status->value,
]);
->get();
return Response::structured($accounts->toArray());
return Response::structured([
'social_accounts' => SocialAccountResource::collection($accounts)->resolve(),
]);
}
}

View file

@ -5,6 +5,7 @@
namespace App\Mcp\Tools\SocialAccount;
use App\Actions\SocialAccount\ToggleSocialAccount;
use App\Http\Resources\Api\SocialAccountResource;
use App\Models\SocialAccount;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Laravel\Mcp\Request;
@ -13,7 +14,7 @@
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;
#[Description('Toggle a social account active/inactive. Returns the updated account.')]
#[Description('Toggle a social account active/inactive. When inactive, the account is skipped during scheduled publishing. Returns the updated account.')]
class ToggleSocialAccountTool extends Tool
{
public function handle(Request $request): Response|ResponseFactory
@ -22,25 +23,16 @@ public function handle(Request $request): Response|ResponseFactory
'account_id' => ['required', 'string', 'uuid'],
]);
$workspace = $request->user()->currentWorkspace;
$account = SocialAccount::where('workspace_id', $workspace->id)
->where('id', data_get($validated, 'account_id'))
->first();
$account = SocialAccount::where('workspace_id', $request->user()->current_workspace_id)
->find(data_get($validated, 'account_id'));
if (! $account) {
return Response::error('Account not found.');
return Response::error('Social account not found.');
}
ToggleSocialAccount::execute($account);
$account = ToggleSocialAccount::execute($account);
return Response::structured([
'id' => $account->id,
'platform' => $account->platform->value,
'display_name' => $account->display_name,
'username' => $account->username,
'is_active' => $account->is_active,
'status' => $account->status->value,
]);
return Response::structured((new SocialAccountResource($account))->resolve());
}
public function schema(JsonSchema $schema): array

View file

@ -4,6 +4,7 @@
namespace App\Mcp\Tools\Workspace;
use App\Http\Resources\Api\WorkspaceResource;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
@ -12,11 +13,13 @@
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
#[IsReadOnly]
#[Description('Get the current workspace details including name.')]
#[Description('Get the current workspace details (id, name, timestamps).')]
class GetWorkspaceTool extends Tool
{
public function handle(Request $request): ResponseFactory
{
return Response::structured($request->user()->currentWorkspace->toArray());
$workspace = $request->user()->currentWorkspace;
return Response::structured((new WorkspaceResource($workspace))->resolve());
}
}

34
config/cors.php Normal file
View file

@ -0,0 +1,34 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Cross-Origin Resource Sharing (CORS) Configuration
|--------------------------------------------------------------------------
|
| Here you may configure your settings for cross-origin resource sharing
| or "CORS". This determines what cross-origin operations may execute
| in web browsers. You are free to adjust these settings as needed.
|
| To learn more: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS
|
*/
'paths' => ['api/*', 'sanctum/csrf-cookie', 'oauth/*', 'mcp/*', '.well-known/*'],
'allowed_methods' => ['*'],
'allowed_origins' => ['*'],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => false,
];

1
lang/php_en.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_es.json Normal file

File diff suppressed because one or more lines are too long

1
lang/php_pt-BR.json Normal file

File diff suppressed because one or more lines are too long

View file

@ -10,6 +10,7 @@
use App\Models\AccessToken;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -31,22 +32,55 @@ function attachToken(User $user, Workspace $workspace): AccessToken
return $token->refresh();
}
test('can list api keys', function () {
test('list api keys returns wrapped api_keys array with ApiKeyResource shape', function () {
attachToken($this->user, $this->workspace);
attachToken($this->user, $this->workspace);
$response = TryPostServer::actingAs($this->user)
->tool(ListApiKeysTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('api_keys', 2, function (AssertableJson $key) {
$key->hasAll(['id', 'name', 'last_used_at', 'expires_at', 'created_at'])
->missing('token')
->missing('user_id')
->missing('workspace_id')
->missing('client_id');
});
});
});
test('can create api key', function () {
test('list api keys excludes OAuth tokens (workspace_id null)', function () {
// Personal Access Token (workspace bound)
attachToken($this->user, $this->workspace);
// OAuth-flow token (workspace_id null — like ChatGPT MCP session)
$oauthResult = $this->user->createToken('OAuth Session');
AccessToken::find($oauthResult->token->id)
->forceFill(['workspace_id' => null])
->saveQuietly();
$response = TryPostServer::actingAs($this->user)
->tool(ListApiKeysTool::class, []);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('api_keys', 1)->etc();
});
});
test('create api key returns plain token only at creation', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateApiKeyTool::class, ['name' => 'My Key']);
$response->assertOk();
$response->assertSee('My Key');
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('name', 'My Key')
->has('token')
->hasAll(['id', 'last_used_at', 'expires_at', 'created_at'])
->etc();
});
expect(AccessToken::where('user_id', $this->user->id)
->where('workspace_id', $this->workspace->id)
@ -60,17 +94,29 @@ function attachToken(User $user, Workspace $workspace): AccessToken
$response->assertHasErrors();
});
test('can revoke api key', function () {
test('create api key rejects expires_at in the past', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateApiKeyTool::class, [
'name' => 'Past Key',
'expires_at' => '2020-01-01',
]);
$response->assertHasErrors();
});
test('delete api key marks revoked', function () {
$token = attachToken($this->user, $this->workspace);
$response = TryPostServer::actingAs($this->user)
->tool(DeleteApiKeyTool::class, ['api_key_id' => $token->id]);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(['deleted' => true]);
expect($token->refresh()->revoked)->toBeTrue();
});
test('cannot delete api key from another workspace', function () {
test('cannot delete api key from another user', function () {
$otherUser = User::factory()->create();
$otherWorkspace = Workspace::factory()->create([
'account_id' => $otherUser->account_id,
@ -84,6 +130,19 @@ function attachToken(User $user, Workspace $workspace): AccessToken
$response->assertHasErrors(['API key not found.']);
});
test('cannot delete OAuth-flow token through this tool', function () {
// OAuth token has workspace_id null — DeleteApiKeyTool filter excludes it
$oauthResult = $this->user->createToken('OAuth Session');
$oauthToken = AccessToken::find($oauthResult->token->id);
$oauthToken->forceFill(['workspace_id' => null])->saveQuietly();
$response = TryPostServer::actingAs($this->user)
->tool(DeleteApiKeyTool::class, ['api_key_id' => $oauthToken->id]);
$response->assertHasErrors(['API key not found.']);
expect($oauthToken->refresh()->revoked)->toBeFalse();
});
test('delete api key validates api_key_id required', function () {
$response = TryPostServer::actingAs($this->user)
->tool(DeleteApiKeyTool::class, []);

View file

@ -11,6 +11,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceLabel;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -19,24 +20,50 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can list labels', function () {
test('list labels returns wrapped labels array with LabelResource shape', function () {
WorkspaceLabel::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(ListLabelsTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('labels', 2, function (AssertableJson $label) {
$label->hasAll(['id', 'name', 'color', 'created_at', 'updated_at'])
->missing('workspace_id');
});
});
});
test('can create label', function () {
test('list labels only returns own workspace labels', function () {
WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$otherWorkspace = Workspace::factory()->create();
WorkspaceLabel::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(ListLabelsTool::class, []);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('labels', 1)->etc();
});
});
test('create label returns LabelResource shape', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateLabelTool::class, [
'name' => 'Important',
'color' => '#FF0000',
]);
$response->assertOk();
$response->assertSee('Important');
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('name', 'Important')
->where('color', '#FF0000')
->missing('workspace_id')
->etc();
});
expect($this->workspace->labels()->count())->toBe(1);
});
@ -57,7 +84,7 @@
$response->assertHasErrors();
});
test('can update label', function () {
test('update label returns updated LabelResource', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
@ -67,8 +94,12 @@
'color' => '#00FF00',
]);
$response->assertOk();
$response->assertSee('Updated');
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('name', 'Updated')
->where('color', '#00FF00')
->etc();
});
});
test('cannot update label from another workspace', function () {
@ -85,13 +116,15 @@
$response->assertHasErrors(['Label not found.']);
});
test('can delete label', function () {
test('delete label removes from db', function () {
$label = WorkspaceLabel::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(DeleteLabelTool::class, ['label_id' => $label->id]);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(['deleted' => true]);
expect(WorkspaceLabel::find($label->id))->toBeNull();
});

View file

@ -13,6 +13,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -26,7 +27,7 @@
]);
});
test('can list posts', function () {
test('list posts returns wrapped posts array with PostResource shape', function () {
Post::factory()->count(3)->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -35,10 +36,17 @@
$response = TryPostServer::actingAs($this->user)
->tool(ListPostsTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('posts', 3, function (AssertableJson $post) {
$post->hasAll(['id', 'content', 'media', 'status', 'scheduled_at', 'published_at', 'platforms', 'labels', 'created_at', 'updated_at'])
->missing('user_id')
->missing('workspace_id');
});
});
});
test('listing only returns own workspace posts', function () {
test('list posts only returns own workspace posts', function () {
Post::factory()->create(['workspace_id' => $this->workspace->id, 'user_id' => $this->user->id]);
$otherWorkspace = Workspace::factory()->create();
@ -47,22 +55,33 @@
$response = TryPostServer::actingAs($this->user)
->tool(ListPostsTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('posts', 1)->etc();
});
});
test('can get a post by id', function () {
test('get post returns PostResource shape', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
'content' => 'Hello world',
]);
$response = TryPostServer::actingAs($this->user)
->tool(GetPostTool::class, ['post_id' => $post->id]);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) use ($post) {
$json->where('id', $post->id)
->where('content', 'Hello world')
->missing('user_id')
->missing('workspace_id')
->etc();
});
});
test('cannot get post from another workspace', function () {
test('get post 404 from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $otherWorkspace->id, 'user_id' => $this->user->id]);
@ -72,22 +91,46 @@
$response->assertHasErrors(['Post not found.']);
});
test('can create a post', function () {
test('create post with content and date', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, []);
->tool(CreatePostTool::class, [
'content' => 'My new post',
'date' => '2026-04-15',
]);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('content', 'My new post')
->where('status', 'draft')
->where('scheduled_at', '2026-04-15 09:00:00')
->etc();
});
$response->assertOk();
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
});
test('can create a post with date', function () {
test('create post without args creates empty draft for today', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, ['date' => '2026-04-15']);
->tool(CreatePostTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('content', '')
->where('status', 'draft')
->etc();
});
expect(Post::where('workspace_id', $this->workspace->id)->count())->toBe(1);
});
test('can delete a post', function () {
test('create post rejects invalid date format', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreatePostTool::class, ['date' => 'not-a-date']);
$response->assertHasErrors();
});
test('delete post removes from db', function () {
$post = Post::factory()->create([
'workspace_id' => $this->workspace->id,
'user_id' => $this->user->id,
@ -96,11 +139,13 @@
$response = TryPostServer::actingAs($this->user)
->tool(DeletePostTool::class, ['post_id' => $post->id]);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(['deleted' => true]);
expect(Post::find($post->id))->toBeNull();
});
test('cannot delete post from another workspace', function () {
test('delete post 404 from another workspace', function () {
$otherWorkspace = Workspace::factory()->create();
$post = Post::factory()->create(['workspace_id' => $otherWorkspace->id, 'user_id' => $this->user->id]);

View file

@ -11,6 +11,7 @@
use App\Models\User;
use App\Models\Workspace;
use App\Models\WorkspaceSignature;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -19,24 +20,50 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can list signatures', function () {
test('list signatures returns wrapped signatures array with SignatureResource shape', function () {
WorkspaceSignature::factory()->count(2)->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(ListSignaturesTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('signatures', 2, function (AssertableJson $sig) {
$sig->hasAll(['id', 'name', 'content', 'created_at', 'updated_at'])
->missing('workspace_id');
});
});
});
test('can create signature', function () {
test('list signatures only returns own workspace signatures', function () {
WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$otherWorkspace = Workspace::factory()->create();
WorkspaceSignature::factory()->create(['workspace_id' => $otherWorkspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(ListSignaturesTool::class, []);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('signatures', 1)->etc();
});
});
test('create signature returns SignatureResource shape', function () {
$response = TryPostServer::actingAs($this->user)
->tool(CreateSignatureTool::class, [
'name' => 'Marketing',
'content' => '#marketing #social',
]);
$response->assertOk();
$response->assertSee('Marketing');
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('name', 'Marketing')
->where('content', '#marketing #social')
->missing('workspace_id')
->etc();
});
expect($this->workspace->signatures()->count())->toBe(1);
});
@ -47,7 +74,7 @@
$response->assertHasErrors();
});
test('can update signature', function () {
test('update signature returns updated SignatureResource', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
@ -57,8 +84,12 @@
'content' => '#updated',
]);
$response->assertOk();
$response->assertSee('Updated');
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('name', 'Updated')
->where('content', '#updated')
->etc();
});
});
test('cannot update signature from another workspace', function () {
@ -75,13 +106,15 @@
$response->assertHasErrors(['Signature not found.']);
});
test('can delete signature', function () {
test('delete signature removes from db', function () {
$signature = WorkspaceSignature::factory()->create(['workspace_id' => $this->workspace->id]);
$response = TryPostServer::actingAs($this->user)
->tool(DeleteSignatureTool::class, ['signature_id' => $signature->id]);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(['deleted' => true]);
expect(WorkspaceSignature::find($signature->id))->toBeNull();
});

View file

@ -10,6 +10,7 @@
use App\Models\SocialAccount;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -18,9 +19,7 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
// ListSocialAccountsTool
test('can list social accounts', function () {
test('list returns wrapped social_accounts array with SocialAccountResource shape', function () {
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
@ -33,10 +32,18 @@
$response = TryPostServer::actingAs($this->user)
->tool(ListSocialAccountsTool::class, []);
$response->assertOk();
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('social_accounts', 2, function (AssertableJson $account) {
$account->hasAll(['id', 'platform', 'display_name', 'username', 'is_active', 'status'])
->missing('access_token')
->missing('refresh_token')
->missing('workspace_id');
});
});
});
test('listing only returns own workspace accounts', function () {
test('list only returns own workspace accounts', function () {
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
@ -51,11 +58,13 @@
$response = TryPostServer::actingAs($this->user)
->tool(ListSocialAccountsTool::class, []);
$response->assertOk();
$response->assertDontSee(Platform::X->value);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->has('social_accounts', 1)->etc();
});
});
test('list does not expose tokens', function () {
test('list never exposes access_token or refresh_token', function () {
SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
@ -69,9 +78,7 @@
$response->assertDontSee('secret-token-123');
});
// ToggleSocialAccountTool
test('can toggle social account to inactive', function () {
test('toggle returns updated SocialAccountResource', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
@ -79,15 +86,20 @@
]);
$response = TryPostServer::actingAs($this->user)
->tool(ToggleSocialAccountTool::class, [
'account_id' => $account->id,
]);
->tool(ToggleSocialAccountTool::class, ['account_id' => $account->id]);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) use ($account) {
$json->where('id', $account->id)
->where('is_active', false)
->where('platform', 'linkedin')
->etc();
});
$response->assertOk();
expect($account->fresh()->is_active)->toBeFalse();
});
test('can toggle social account to active', function () {
test('toggle inactive account becomes active', function () {
$account = SocialAccount::factory()->create([
'workspace_id' => $this->workspace->id,
'platform' => Platform::LinkedIn,
@ -95,9 +107,7 @@
]);
$response = TryPostServer::actingAs($this->user)
->tool(ToggleSocialAccountTool::class, [
'account_id' => $account->id,
]);
->tool(ToggleSocialAccountTool::class, ['account_id' => $account->id]);
$response->assertOk();
expect($account->fresh()->is_active)->toBeTrue();
@ -111,16 +121,21 @@
]);
$response = TryPostServer::actingAs($this->user)
->tool(ToggleSocialAccountTool::class, [
'account_id' => $account->id,
]);
->tool(ToggleSocialAccountTool::class, ['account_id' => $account->id]);
$response->assertHasErrors();
$response->assertHasErrors(['Social account not found.']);
});
test('toggle validates account_id is required', function () {
test('toggle validates account_id required', function () {
$response = TryPostServer::actingAs($this->user)
->tool(ToggleSocialAccountTool::class, []);
$response->assertHasErrors();
});
test('toggle validates account_id is uuid', function () {
$response = TryPostServer::actingAs($this->user)
->tool(ToggleSocialAccountTool::class, ['account_id' => 'not-a-uuid']);
$response->assertHasErrors();
});

View file

@ -7,6 +7,7 @@
use App\Mcp\Tools\Workspace\GetWorkspaceTool;
use App\Models\User;
use App\Models\Workspace;
use Illuminate\Testing\Fluent\AssertableJson;
beforeEach(function () {
$this->user = User::factory()->create();
@ -15,10 +16,18 @@
$this->user->update(['current_workspace_id' => $this->workspace->id]);
});
test('can get workspace details', function () {
test('get workspace returns sanitized WorkspaceResource shape', function () {
$response = TryPostServer::actingAs($this->user)
->tool(GetWorkspaceTool::class, []);
$response->assertOk();
$response->assertSee($this->workspace->name);
$response->assertOk()
->assertStructuredContent(function (AssertableJson $json) {
$json->where('id', $this->workspace->id)
->where('name', $this->workspace->name)
->hasAll(['created_at', 'updated_at'])
->missing('account_id')
->missing('user_id')
->missing('brand_color')
->missing('content_language');
});
});