From d13eaa45c08f88268df24dd7dce54fd069e92fff Mon Sep 17 00:00:00 2001 From: Oliver <20591763+Cryptoom@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:28:26 +0300 Subject: [PATCH 1/2] feat(mcp): add update-workspace-tool for brand settings GetWorkspaceTool is read-only; there was no API or MCP path to write a workspace's brand settings (name, colors, voice traits, font, image style, content language). Adds UpdateWorkspaceTool mirroring UpdateWorkspaceRequest's validation with PATCH semantics (only passed fields change), gated on the same 'update' ability as the settings UI. Registered as fork Patch 1 in PATCHES.md. --- PATCHES.md | 28 ++++++- app/Mcp/Servers/TryPostServer.php | 2 + .../Tools/Workspace/UpdateWorkspaceTool.php | 84 +++++++++++++++++++ tests/Feature/Mcp/WorkspaceToolTest.php | 69 +++++++++++++++ 4 files changed, 179 insertions(+), 4 deletions(-) create mode 100644 app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php diff --git a/PATCHES.md b/PATCHES.md index 4f47f5ef3f..d0625a0817 100644 --- a/PATCHES.md +++ b/PATCHES.md @@ -18,10 +18,30 @@ noch nachtragen sobald der erste echte Patch aktiv ist). ## Aktive Patches -Keine. Diese Datei ist bewusst als leeres Geruest angelegt (Olli-Entscheidung 25.08.2026: -"Fork jetzt anlegen, Infrastruktur vorbereiten"), damit ein spaeterer Patch (z.B. der -`is_aigc`-Composer-UI-Toggle aus dem TryPost-Phase-4-Planungs-Pass, falls das GitHub-Issue -nicht zeitnah beantwortet wird) sofort in der etablierten Struktur landet statt einer neuen. +### Patch 1 ยท update-workspace-tool + +Fuegt ein schreibendes MCP-Tool fuer die Workspace-Brand-Settings hinzu. Upstream hat nur +`GetWorkspaceTool` (read-only, `#[IsReadOnly]`), keinen API- oder MCP-Weg um `name`, +`brand_website`, `brand_description`, `brand_voice_traits`, `brand_color`, `background_color`, +`text_color`, `brand_font`, `image_style` oder `content_language` programmatisch zu setzen. Ohne +diesen Patch braucht jedes automatisierte Kunden-Onboarding (neuer Workspace, Brand per Skript +vorbefuellen) einen manuellen Umweg ueber die UI. + +- **Dateien**: + - `app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php` (neu). Validiert dieselben Regeln wie + `App\Http\Requests\App\Workspace\UpdateWorkspaceRequest`, aber PATCH-Semantik (`sometimes` + statt `required`, nur uebergebene Felder werden geschrieben). Autorisierung ueber + `AuthorizesMcpTool::authorizeCurrentWorkspace($request, 'update', ...)`, spiegelt + `WorkspaceController::updateSettings()`. + - `app/Mcp/Servers/TryPostServer.php` (Import + Registry-Eintrag unter "Workspace"). +- **Marker-String zum Wiedererkennen nach `git merge upstream/main`**: Klassenname + `UpdateWorkspaceTool` (Datei existiert upstream nicht) plus der Registry-Kommentar + `// Workspace` in `TryPostServer.php` (dort pruefen ob `UpdateWorkspaceTool::class` noch + direkt nach `GetWorkspaceTool::class` steht). +- **Test**: `tests/Feature/Mcp/WorkspaceToolTest.php` (4 neue Tests: valides Update, ungueltige + `brand_color`, Cross-Workspace-Isolation, Ability-Check fuer Member ohne Admin/Owner-Rolle). +- Bricht bei einem Merge NUR falls upstream `UpdateWorkspaceRequest`, `WorkspaceResource` oder + die `update`-Ability in `WorkspacePolicy` umbenennt/entfernt, dann Patch-Regeln nachziehen. ## Wie ein neuer Patch hier reinkommt diff --git a/app/Mcp/Servers/TryPostServer.php b/app/Mcp/Servers/TryPostServer.php index a8d98810db..0f3daa1897 100644 --- a/app/Mcp/Servers/TryPostServer.php +++ b/app/Mcp/Servers/TryPostServer.php @@ -35,6 +35,7 @@ use App\Mcp\Tools\SocialAccount\ListSocialAccountsTool; use App\Mcp\Tools\SocialAccount\ToggleSocialAccountTool; use App\Mcp\Tools\Workspace\GetWorkspaceTool; +use App\Mcp\Tools\Workspace\UpdateWorkspaceTool; use Laravel\Mcp\Server; use Laravel\Mcp\Server\Attributes\Icon; use Laravel\Mcp\Server\Attributes\Instructions; @@ -91,6 +92,7 @@ class TryPostServer extends Server // Workspace GetWorkspaceTool::class, + UpdateWorkspaceTool::class, // API Keys ListApiKeysTool::class, diff --git a/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php b/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php new file mode 100644 index 0000000000..c0fed39d3c --- /dev/null +++ b/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php @@ -0,0 +1,84 @@ +authorizeCurrentWorkspace( + $request, + 'update', + 'Not authorized to update this workspace.', + ); + + if (! $workspace instanceof Workspace) { + return $workspace; + } + + $hex = ['nullable', 'string', 'regex:/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/']; + + $validated = $request->validate([ + 'name' => ['sometimes', 'required', 'string', 'max:255'], + 'brand_website' => ['sometimes', 'nullable', 'url', 'max:255'], + 'brand_description' => ['sometimes', 'nullable', 'string', 'max:2000'], + 'brand_voice_traits' => ['sometimes', 'nullable', 'array'], + 'brand_voice_traits.*' => ['string', Rule::enum(BrandVoiceTrait::class)], + 'brand_color' => ['sometimes', ...$hex], + 'background_color' => ['sometimes', ...$hex], + 'text_color' => ['sometimes', ...$hex], + 'brand_font' => ['sometimes', 'required', 'string', Rule::in(BrandFont::values())], + 'image_style' => ['sometimes', 'required', 'string', Rule::in(ImageStyle::values())], + 'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())], + ]); + + // PATCH semantics: only the fillable fields the caller actually passed + // are written. Mirrors UpdateWorkspaceRequest's field set minus + // logo_url (logo upload has its own dedicated flow, not brand settings). + $payload = array_intersect_key($validated, array_flip($workspace->getFillable())); + + if ($payload !== []) { + $workspace->update($payload); + } + + return Response::structured((new WorkspaceResource($workspace))->resolve()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'name' => $schema->string()->description('Workspace display name.'), + 'brand_website' => $schema->string()->description('Brand website URL.'), + 'brand_description' => $schema->string()->description('Brand description, up to 2000 characters.'), + 'brand_voice_traits' => $schema->array() + ->items($schema->string()->enum(BrandVoiceTrait::values())) + ->description('Brand voice traits (replaces the existing set). See the grouped trait list for single-select spectrum groups vs additive style traits.'), + 'brand_color' => $schema->string()->description('Hex color code (e.g. #FF5733).'), + 'background_color' => $schema->string()->description('Hex color code (e.g. #FFFFFF).'), + 'text_color' => $schema->string()->description('Hex color code (e.g. #111111).'), + 'brand_font' => $schema->string()->enum(BrandFont::values())->description('Brand font family name.'), + 'image_style' => $schema->string()->enum(ImageStyle::values())->description('AI image generation style.'), + 'content_language' => $schema->string()->enum(ContentLanguage::values())->description('Content language code (e.g. "en", "de").'), + ]; + } +} diff --git a/tests/Feature/Mcp/WorkspaceToolTest.php b/tests/Feature/Mcp/WorkspaceToolTest.php index 07da0c6b2d..1d7094f699 100644 --- a/tests/Feature/Mcp/WorkspaceToolTest.php +++ b/tests/Feature/Mcp/WorkspaceToolTest.php @@ -5,6 +5,7 @@ use App\Enums\UserWorkspace\Role; use App\Mcp\Servers\TryPostServer; use App\Mcp\Tools\Workspace\GetWorkspaceTool; +use App\Mcp\Tools\Workspace\UpdateWorkspaceTool; use App\Models\User; use App\Models\Workspace; use Illuminate\Testing\Fluent\AssertableJson; @@ -31,3 +32,71 @@ ->missing('content_language'); }); }); + +test('update workspace sets a valid field and leaves others untouched', function () { + $this->workspace->members()->updateExistingPivot($this->user->id, ['role' => Role::Admin->value]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdateWorkspaceTool::class, [ + 'brand_description' => 'A friendly, no hype coffee brand.', + ]); + + $response->assertOk() + ->assertStructuredContent(function (AssertableJson $json) { + $json->where('id', $this->workspace->id) + ->where('name', $this->workspace->name) + ->hasAll(['created_at', 'updated_at']); + }); + + $this->workspace->refresh(); + + expect($this->workspace->brand_description)->toBe('A friendly, no hype coffee brand.') + ->and($this->workspace->content_language)->toBe('en'); +}); + +test('update workspace rejects an invalid brand color', function () { + $this->workspace->members()->updateExistingPivot($this->user->id, ['role' => Role::Admin->value]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdateWorkspaceTool::class, [ + 'brand_color' => 'not-a-hex-color', + ]); + + $response->assertHasErrors(); + + expect($this->workspace->fresh()->brand_color)->toBeNull(); +}); + +test('update workspace cannot change a workspace outside the caller current workspace', function () { + $this->workspace->members()->updateExistingPivot($this->user->id, ['role' => Role::Admin->value]); + + $otherWorkspace = Workspace::factory()->create(['name' => 'Untouched Workspace']); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdateWorkspaceTool::class, [ + 'name' => 'Hijacked', + ]); + + $response->assertOk(); + + expect($otherWorkspace->fresh()->name)->toBe('Untouched Workspace') + ->and($this->workspace->fresh()->name)->toBe('Hijacked'); +}); + +test('update workspace denies a member without admin or owner role', function () { + // $this->user auto-owns its own freshly-created account (see UserFactory::configure()), + // so a real "non-owner member" needs a second user sharing that SAME account, the way + // McpRoleAuthorizationTest builds its viewer/member fixtures. + $member = User::factory()->create(['account_id' => $this->user->account_id]); + $this->workspace->members()->attach($member->id, ['role' => Role::Member->value]); + $member->update(['current_workspace_id' => $this->workspace->id]); + + $response = TryPostServer::actingAs($member) + ->tool(UpdateWorkspaceTool::class, [ + 'name' => 'Should Not Apply', + ]); + + $response->assertHasErrors(['Not authorized to update this workspace.']); + + expect($this->workspace->fresh()->name)->not->toBe('Should Not Apply'); +}); From 70a6a63f3d7a3779ff21d0a51a4b9a33baf27cfd Mon Sep 17 00:00:00 2001 From: Oliver <20591763+Cryptoom@users.noreply.github.com> Date: Tue, 25 Aug 2026 20:47:34 +0300 Subject: [PATCH 2/2] fix(mcp): coerce brand_voice_traits and rename misleading test UpdateWorkspaceTool validated brand_voice_traits against the enum but never ran BrandVoiceTrait::coerce(), so an update could store an incoherent set (e.g. both formal and casual). This is the first programmatic/unattended MCP write path, so coerce it here before the values land in the update payload. Also rename a test whose name implied a cross-workspace injection vector that the tool schema does not expose. The tool always resolves the caller's current workspace; there is no workspace_id parameter to attack. --- .../Tools/Workspace/UpdateWorkspaceTool.php | 4 ++++ tests/Feature/Mcp/WorkspaceToolTest.php | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php b/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php index c0fed39d3c..a0b5fbf367 100644 --- a/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php +++ b/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php @@ -52,6 +52,10 @@ public function handle(Request $request): Response|ResponseFactory 'content_language' => ['sometimes', 'string', Rule::in(ContentLanguage::values())], ]); + if (array_key_exists('brand_voice_traits', $validated) && $validated['brand_voice_traits'] !== null) { + $validated['brand_voice_traits'] = BrandVoiceTrait::coerce($validated['brand_voice_traits']); + } + // PATCH semantics: only the fillable fields the caller actually passed // are written. Mirrors UpdateWorkspaceRequest's field set minus // logo_url (logo upload has its own dedicated flow, not brand settings). diff --git a/tests/Feature/Mcp/WorkspaceToolTest.php b/tests/Feature/Mcp/WorkspaceToolTest.php index 1d7094f699..e7f7e9ec79 100644 --- a/tests/Feature/Mcp/WorkspaceToolTest.php +++ b/tests/Feature/Mcp/WorkspaceToolTest.php @@ -67,7 +67,7 @@ expect($this->workspace->fresh()->brand_color)->toBeNull(); }); -test('update workspace cannot change a workspace outside the caller current workspace', function () { +test('update workspace only touches the caller current workspace, other workspaces are untouched', function () { $this->workspace->members()->updateExistingPivot($this->user->id, ['role' => Role::Admin->value]); $otherWorkspace = Workspace::factory()->create(['name' => 'Untouched Workspace']); @@ -100,3 +100,21 @@ expect($this->workspace->fresh()->name)->not->toBe('Should Not Apply'); }); + +test('update workspace coerces brand_voice_traits to a coherent selection', function () { + $this->workspace->members()->updateExistingPivot($this->user->id, ['role' => Role::Admin->value]); + + $response = TryPostServer::actingAs($this->user) + ->tool(UpdateWorkspaceTool::class, [ + 'brand_voice_traits' => ['formal', 'casual', 'direct'], + ]); + + $response->assertOk(); + + $traits = $this->workspace->fresh()->brand_voice_traits; + + expect($traits)->toContain('direct') + ->and($traits)->toContain('formal') + ->and($traits)->not->toContain('casual') + ->and($traits)->toHaveCount(2); +});