Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 24 additions & 4 deletions PATCHES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions app/Mcp/Servers/TryPostServer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -91,6 +92,7 @@ class TryPostServer extends Server

// Workspace
GetWorkspaceTool::class,
UpdateWorkspaceTool::class,

// API Keys
ListApiKeysTool::class,
Expand Down
88 changes: 88 additions & 0 deletions app/Mcp/Tools/Workspace/UpdateWorkspaceTool.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
<?php

declare(strict_types=1);

namespace App\Mcp\Tools\Workspace;

use App\Enums\Workspace\BrandFont;
use App\Enums\Workspace\BrandVoiceTrait;
use App\Enums\Workspace\ContentLanguage;
use App\Enums\Workspace\ImageStyle;
use App\Http\Resources\Api\WorkspaceResource;
use App\Mcp\Concerns\AuthorizesMcpTool;
use App\Models\Workspace;
use Illuminate\Contracts\JsonSchema\JsonSchema;
use Illuminate\Validation\Rule;
use Laravel\Mcp\Request;
use Laravel\Mcp\Response;
use Laravel\Mcp\ResponseFactory;
use Laravel\Mcp\Server\Attributes\Description;
use Laravel\Mcp\Server\Tool;

#[Description('Update the current workspace brand settings (name, website, description, voice traits, colors, font, image style, content language). All fields are optional, only the fields you pass are changed.')]
class UpdateWorkspaceTool extends Tool
{
use AuthorizesMcpTool;

public function handle(Request $request): Response|ResponseFactory
{
$workspace = $this->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").'),
];
}
}
87 changes: 87 additions & 0 deletions tests/Feature/Mcp/WorkspaceToolTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
});