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..a0b5fbf367 --- /dev/null +++ b/app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php @@ -0,0 +1,88 @@ +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())], + ]); + + 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). + $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..e7f7e9ec79 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,89 @@ ->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 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']); + + $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'); +}); + +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); +});