From 23094f891ed29786a2466bd18a711321fd20420f Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 12:34:55 +0200 Subject: [PATCH 1/5] Give Features and Assets their own pages; turn project overview into a dashboard - Add features.index page (project/{project}/features) with the full feature list, create, and drag-reorder UI moved out of project.show - Add assets.index page (project/{project}/assets) wrapping the existing project-assets-panel component - Rewrite project.show as a true overview: project header + 6-card nav grid linking to Features, Stories, Plans, Approvals, Runs, Assets - Fix project description to render via instead of {{ }} - Add Assets and Features sidebar links; Features now points to the dedicated features.index route instead of the buried #features anchor - Update ProjectShowTest to call features-related actions on the features.index component Co-Authored-By: Claude Opus 4.7 (1M context) --- resources/views/layouts/app/sidebar.blade.php | 5 +- .../\342\232\241project-assets.blade.php" | 32 ++++ .../features/\342\232\241index.blade.php" | 158 ++++++++++++++++ .../projects/\342\232\241show.blade.php" | 174 +++--------------- routes/web.php | 2 + tests/Feature/ProjectShowTest.php | 14 +- 6 files changed, 230 insertions(+), 155 deletions(-) create mode 100644 "resources/views/pages/context-items/\342\232\241project-assets.blade.php" create mode 100644 "resources/views/pages/features/\342\232\241index.blade.php" diff --git a/resources/views/layouts/app/sidebar.blade.php b/resources/views/layouts/app/sidebar.blade.php index 1a0c5e2..c6a39ea 100644 --- a/resources/views/layouts/app/sidebar.blade.php +++ b/resources/views/layouts/app/sidebar.blade.php @@ -30,7 +30,7 @@ {{ __('Overview') }} - + {{ __('Features') }} @@ -48,6 +48,9 @@ {{ __('Repos') }} + + {{ __('Assets') }} + @else diff --git "a/resources/views/pages/context-items/\342\232\241project-assets.blade.php" "b/resources/views/pages/context-items/\342\232\241project-assets.blade.php" new file mode 100644 index 0000000..d83db8c --- /dev/null +++ "b/resources/views/pages/context-items/\342\232\241project-assets.blade.php" @@ -0,0 +1,32 @@ +project_id = $project; + abort_unless($this->project, 404); + } + + #[Computed] + public function project(): ?Project + { + return Project::query() + ->whereIn('id', Auth::user()->accessibleProjectIds()) + ->find($this->project_id); + } +}; ?> + +
+ +
diff --git "a/resources/views/pages/features/\342\232\241index.blade.php" "b/resources/views/pages/features/\342\232\241index.blade.php" new file mode 100644 index 0000000..a0cc7c6 --- /dev/null +++ "b/resources/views/pages/features/\342\232\241index.blade.php" @@ -0,0 +1,158 @@ +project_id = $project; + abort_unless($this->project, 404); + } + + #[Computed] + public function project(): ?Project + { + return Project::query() + ->whereIn('id', Auth::user()->accessibleProjectIds()) + ->find($this->project_id); + } + + #[Computed] + public function features() + { + return $this->project + ? $this->project->features() + ->withCount('stories') + ->orderBy('position') + ->orderBy('id') + ->get() + : collect(); + } + + public function canManageFeatures(): bool + { + $project = $this->project; + + return $project !== null && Auth::user()->canApproveInProject($project); + } + + /** + * @param array $orderedIds Feature IDs in the new visual order. + */ + public function reorderFeatures(array $orderedIds): void + { + $project = $this->project; + abort_unless($project, 404); + abort_unless(Auth::user()->canApproveInProject($project), 403); + + app(PositionReorderer::class)->reorder('features', 'project_id', (int) $project->id, $orderedIds); + + unset($this->features); + } + + public function createFeature(): void + { + $project = $this->project; + abort_unless($project, 404); + abort_unless(Auth::user()->canApproveInProject($project), 403); + + $this->validate(['newFeatureName' => 'required|string|max:255']); + + Feature::create([ + 'project_id' => $project->id, + 'name' => $this->newFeatureName, + 'description' => $this->newFeatureDescription ?: null, + 'status' => FeatureStatus::Proposed, + ]); + + $this->reset(['newFeatureName', 'newFeatureDescription']); + unset($this->features); + } +}; ?> + +
+
+ {{ __('Features') }} + @if ($this->canManageFeatures()) + + {{ __('+ New feature') }} + + @endif +
+ + @php $canReorderFeatures = $this->canManageFeatures() && $this->features->count() > 1; @endphp +
+ @forelse ($this->features as $feature) +
+ +
+ @if ($canReorderFeatures) + + @endif +
+ + {{ $feature->name }} + + @if ($feature->description) + + @endif +
+ {{ $feature->stories_count }} {{ __('stories') }} +
+
+
+ @empty + {{ __('No features yet.') }} + @endforelse +
+ + @if ($this->canManageFeatures()) + +
+ {{ __('New feature') }} + + +
+ + {{ __('Cancel') }} + + {{ __('Create feature') }} +
+ +
+ @endif +
diff --git "a/resources/views/pages/projects/\342\232\241show.blade.php" "b/resources/views/pages/projects/\342\232\241show.blade.php" index d50214d..f04b4a0 100644 --- "a/resources/views/pages/projects/\342\232\241show.blade.php" +++ "b/resources/views/pages/projects/\342\232\241show.blade.php" @@ -1,9 +1,6 @@ find($this->project_id); } - #[Computed] - public function features() - { - return $this->project - ? $this->project->features() - ->withCount('stories') - ->orderBy('position') - ->orderBy('id') - ->get() - : collect(); - } - - /** - * @param array $orderedIds Feature IDs in the new visual order. - */ - public function reorderFeatures(array $orderedIds): void - { - $project = $this->project; - abort_unless($project, 404); - abort_unless(Auth::user()->canApproveInProject($project), 403); - - app(PositionReorderer::class)->reorder('features', 'project_id', (int) $project->id, $orderedIds); - - unset($this->features); - } - public function canEditProject(): bool { $project = $this->project; @@ -128,25 +93,6 @@ public function saveEdit(): void unset($this->project); } - public function createFeature(): void - { - $project = $this->project; - abort_unless($project, 404); - abort_unless(Auth::user()->canApproveInProject($project), 403); - - $this->validate(['newFeatureName' => 'required|string|max:255']); - - Feature::create([ - 'project_id' => $project->id, - 'name' => $this->newFeatureName, - 'description' => $this->newFeatureDescription ?: null, - 'status' => FeatureStatus::Proposed, - ]); - - $this->reset(['newFeatureName', 'newFeatureDescription']); - unset($this->features); - } - public function deleteProject(): void { $project = $this->project; @@ -180,8 +126,6 @@ public function deleteProject(): void @if (! $this->project) {{ __('Project not found.') }} @else - @php $canManageFeatures = Auth::user()->canApproveInProject($this->project); @endphp -
@if ($editing) @@ -205,12 +149,16 @@ public function deleteProject(): void @endif @if ($this->project->description) - {{ $this->project->description }} + @endif @endif
-
+
+ +
{{ __('Features') }}
+
{{ __('Organise work into product-level capabilities.') }}
+
{{ __('Stories') }}
{{ __('Browse product contracts and their current plans.') }}
@@ -223,103 +171,35 @@ public function deleteProject(): void
{{ __('Approvals') }}
{{ __('Review story contracts and current plans in separate queues.') }}
+ +
{{ __('Runs') }}
+
{{ __('Track AI agent execution history.') }}
+
+ +
{{ __('Assets') }}
+
{{ __('Reference material shared across all stories in this project.') }}
+
-
-
- {{ __('Features') }} - @if ($canManageFeatures) - - {{ __('+ New feature') }} - - @endif -
- - @php $canReorderFeatures = $canManageFeatures && $this->features->count() > 1; @endphp -
- @forelse ($this->features as $feature) -
- -
- @if ($canReorderFeatures) - - @endif -
- - {{ $feature->name }} - - @if ($feature->description) - - @endif -
- {{ $feature->stories_count }} {{ __('stories') }} -
-
-
- @empty - {{ __('No features yet.') }} - @endforelse -
-
- - - - @if ($canManageFeatures) - -
- {{ __('New feature') }} - - + @if ($this->canDeleteProject()) + +
+ {{ __('Delete project?') }} + {{ __('This permanently removes the project, its features, stories, tasks, subtasks, approvals, and repo attachments. This cannot be undone.') }} +
{{ __('Cancel') }} - {{ __('Create feature') }} + {{ __('Delete project') }}
- +
- - @if ($this->canDeleteProject()) - -
- {{ __('Delete project?') }} - {{ __('This permanently removes the project, its features, stories, tasks, subtasks, approvals, and repo attachments. This cannot be undone.') }} - -
- - {{ __('Cancel') }} - - {{ __('Delete project') }} -
-
-
- @endif @endif @endif diff --git a/routes/web.php b/routes/web.php index c81eab0..97431d2 100644 --- a/routes/web.php +++ b/routes/web.php @@ -23,6 +23,7 @@ Route::livewire('activity', 'pages::activity.index')->name('activity.index'); Route::livewire('projects', 'pages::projects.index')->name('projects.index'); Route::livewire('projects/{project}', 'pages::projects.show')->name('projects.show'); + Route::livewire('projects/{project}/features', 'pages::features.index')->name('features.index'); Route::livewire('projects/{project}/features/{feature}', 'pages::features.show')->name('features.show'); Route::livewire('projects/{project}/stories', 'pages::stories.index')->name('stories.index'); Route::livewire('projects/{project}/stories/create', 'pages::stories.create')->name('stories.create'); @@ -31,6 +32,7 @@ Route::livewire('projects/{project}/approvals', 'pages::approvals.index')->name('approvals.index'); Route::livewire('projects/{project}/runs', 'pages::runs.index')->name('runs.index'); Route::livewire('projects/{project}/repos', 'pages::repos.index')->name('repos.index'); + Route::livewire('projects/{project}/assets', 'pages::context-items.project-assets')->name('assets.index'); Route::livewire('projects/{project}/stories/{story}', 'pages::stories.show')->name('stories.show'); Route::livewire('projects/{project}/stories/{story}/tasks/{task}', 'pages::tasks.show')->name('tasks.show'); Route::livewire('projects/{project}/stories/{story}/subtasks/{subtask}', 'pages::subtasks.show')->name('subtasks.show'); diff --git a/tests/Feature/ProjectShowTest.php b/tests/Feature/ProjectShowTest.php index bbf666d..4877d9f 100644 --- a/tests/Feature/ProjectShowTest.php +++ b/tests/Feature/ProjectShowTest.php @@ -19,11 +19,11 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array return compact('user', 'project'); } -test('admin can create a feature on the project page', function () { +test('admin can create a feature on the features page', function () { ['user' => $user, 'project' => $project] = projectShowScene(); $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->set('newFeatureName', 'Authoring') ->set('newFeatureDescription', 'Story creation flows') ->call('createFeature'); @@ -35,7 +35,7 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array ['user' => $user, 'project' => $project] = projectShowScene(TeamRole::Member); $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->set('newFeatureName', 'NoEntry') ->call('createFeature') ->assertStatus(403); @@ -61,7 +61,7 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->assertSee('Visible Feature'); }); @@ -157,7 +157,7 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->call('reorderFeatures', [$c->id, $a->id, $b->id]); expect($c->fresh()->position)->toBe(1); @@ -176,7 +176,7 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->call('reorderFeatures', [$b->id, $foreign->id, $a->id]); expect($a->fresh()->position)->toBe(1); @@ -192,7 +192,7 @@ function projectShowScene(TeamRole $role = TeamRole::Admin): array $this->actingAs($user); - Livewire::test('pages::projects.show', ['project' => $project->id]) + Livewire::test('pages::features.index', ['project' => $project->id]) ->call('reorderFeatures', [$b->id, $a->id]) ->assertStatus(403); }); From c72dc6667549c95c4328ee1067684ca700a77cfe Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 12:42:39 +0200 Subject: [PATCH 2/5] Add MCP tools and API upload endpoint for context assets MCP tools (text/link, no binary): - add-project-asset: create text/link asset on a project - add-story-asset: create text/link asset on a story (auto-includes, reopens approval per ADR-0015) - list-context-items: list project or story assets by id - update-context-item: update title/body of an existing asset - delete-context-item: delete by id (story scope reopens approval) File upload API (POST /api/v1/assets/upload): - Accepts multipart/form-data with file + project_id or story_id - Authenticated via `Authorization: Bearer {SPECIFY_API_KEY}` - Acting user resolved from MCP_USER_EMAIL (same config as MCP server) - Delegates to AssetUploader so all ADR-0015 contracts are upheld Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/ContextAssetUploadController.php | 72 +++++++++++++++++ app/Http/Middleware/AuthenticateApiKey.php | 31 +++++++ app/Mcp/Servers/SpecifyServer.php | 10 +++ app/Mcp/Tools/AddProjectAssetTool.php | 76 ++++++++++++++++++ app/Mcp/Tools/AddStoryAssetTool.php | 77 ++++++++++++++++++ app/Mcp/Tools/DeleteContextItemTool.php | 51 ++++++++++++ app/Mcp/Tools/ListContextItemsTool.php | 74 +++++++++++++++++ app/Mcp/Tools/UpdateContextItemTool.php | 80 +++++++++++++++++++ bootstrap/app.php | 2 + config/specify.php | 1 + routes/api.php | 9 +++ 11 files changed, 483 insertions(+) create mode 100644 app/Http/Controllers/Api/ContextAssetUploadController.php create mode 100644 app/Http/Middleware/AuthenticateApiKey.php create mode 100644 app/Mcp/Tools/AddProjectAssetTool.php create mode 100644 app/Mcp/Tools/AddStoryAssetTool.php create mode 100644 app/Mcp/Tools/DeleteContextItemTool.php create mode 100644 app/Mcp/Tools/ListContextItemsTool.php create mode 100644 app/Mcp/Tools/UpdateContextItemTool.php create mode 100644 routes/api.php diff --git a/app/Http/Controllers/Api/ContextAssetUploadController.php b/app/Http/Controllers/Api/ContextAssetUploadController.php new file mode 100644 index 0000000..61b6189 --- /dev/null +++ b/app/Http/Controllers/Api/ContextAssetUploadController.php @@ -0,0 +1,72 @@ +validate([ + 'file' => ['required', 'file'], + 'title' => ['nullable', 'string', 'max:255'], + 'project_id' => ['required_without:story_id', 'nullable', 'integer'], + 'story_id' => ['required_without:project_id', 'nullable', 'integer'], + ]); + + $user = Auth::user(); + + if (! empty($validated['story_id'])) { + $story = Story::query()->with('feature')->find($validated['story_id']); + + if (! $story) { + return response()->json(['error' => 'Story not found.'], 404); + } + + $project = $story->feature?->project; + + if (! $project || ! in_array($project->id, $user->accessibleProjectIds(), true)) { + return response()->json(['error' => 'Story not accessible.'], 403); + } + } else { + $project = Project::query()->find($validated['project_id']); + + if (! $project) { + return response()->json(['error' => 'Project not found.'], 404); + } + + if (! in_array($project->id, $user->accessibleProjectIds(), true)) { + return response()->json(['error' => 'Project not accessible.'], 403); + } + + $story = null; + } + + try { + $item = $uploader->store($request->file('file'), $project, $story ?? null, $user); + } catch (InvalidArgumentException $e) { + return response()->json(['error' => $e->getMessage()], 422); + } + + if (isset($validated['title']) && $validated['title'] !== null) { + $item->update(['title' => $validated['title']]); + } + + return response()->json([ + 'id' => $item->id, + 'project_id' => $item->project_id, + 'story_id' => $item->story_id, + 'type' => $item->type->value, + 'title' => $item->title, + 'metadata' => $item->metadata, + ], 201); + } +} diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php new file mode 100644 index 0000000..88077ae --- /dev/null +++ b/app/Http/Middleware/AuthenticateApiKey.php @@ -0,0 +1,31 @@ +bearerToken() !== $configured) { + return response()->json(['error' => 'Unauthorized.'], 401); + } + + $email = config('specify.mcp.user_email'); + $user = $email ? User::query()->where('email', $email)->first() : null; + + if (! $user) { + return response()->json(['error' => 'No acting user configured. Set MCP_USER_EMAIL.'], 403); + } + + auth()->setUser($user); + + return $next($request); + } +} diff --git a/app/Mcp/Servers/SpecifyServer.php b/app/Mcp/Servers/SpecifyServer.php index 30d02d6..b7888b3 100644 --- a/app/Mcp/Servers/SpecifyServer.php +++ b/app/Mcp/Servers/SpecifyServer.php @@ -3,6 +3,11 @@ namespace App\Mcp\Servers; use App\Mcp\Tools\AddAcceptanceCriterionTool; +use App\Mcp\Tools\AddProjectAssetTool; +use App\Mcp\Tools\AddStoryAssetTool; +use App\Mcp\Tools\DeleteContextItemTool; +use App\Mcp\Tools\ListContextItemsTool; +use App\Mcp\Tools\UpdateContextItemTool; use App\Mcp\Tools\AddGithubRepoToProjectTool; use App\Mcp\Tools\AddStoryDependencyTool; use App\Mcp\Tools\ApprovePlanTool; @@ -131,6 +136,11 @@ class SpecifyServer extends Server ListReposTool::class, GetRepoTool::class, ListActivityTool::class, + ListContextItemsTool::class, + AddProjectAssetTool::class, + AddStoryAssetTool::class, + UpdateContextItemTool::class, + DeleteContextItemTool::class, ]; protected array $resources = [ diff --git a/app/Mcp/Tools/AddProjectAssetTool.php b/app/Mcp/Tools/AddProjectAssetTool.php new file mode 100644 index 0000000..1befbec --- /dev/null +++ b/app/Mcp/Tools/AddProjectAssetTool.php @@ -0,0 +1,76 @@ +resolveUser($request); + if ($user instanceof Response) { + return $user; + } + + $validated = $request->validate([ + 'project_id' => ['required', 'integer'], + 'type' => ['required', 'string', 'in:text,link'], + 'title' => ['required', 'string', 'max:255'], + 'body' => ['nullable', 'string', 'max:10000'], + 'url' => ['nullable', 'string', 'url', 'max:2048'], + ]); + + $project = $this->resolveAccessibleProject($validated['project_id'], $user); + if ($project instanceof Response) { + return $project; + } + + $type = ContextItemType::from($validated['type']); + $metadata = $type === ContextItemType::Text + ? ['body' => $validated['body'] ?? ''] + : ['url' => $validated['url'] ?? '']; + + try { + $item = $writer->createProjectItem($project, [ + 'type' => $type, + 'title' => $validated['title'], + 'metadata' => $metadata, + ], $user); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage()); + } + + return Response::json([ + 'id' => $item->id, + 'project_id' => $item->project_id, + 'type' => $item->type->value, + 'title' => $item->title, + 'metadata' => $item->metadata, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer()->description('Project to attach the asset to.')->required(), + 'type' => $schema->string()->enum(['text', 'link'])->description('Asset type: "text" for a note, "link" for a URL.')->required(), + 'title' => $schema->string()->description('Asset title.')->required(), + 'body' => $schema->string()->description('Body text. Required when type is "text".'), + 'url' => $schema->string()->description('URL. Required when type is "link".'), + ]; + } +} diff --git a/app/Mcp/Tools/AddStoryAssetTool.php b/app/Mcp/Tools/AddStoryAssetTool.php new file mode 100644 index 0000000..3d6179f --- /dev/null +++ b/app/Mcp/Tools/AddStoryAssetTool.php @@ -0,0 +1,77 @@ +resolveUser($request); + if ($user instanceof Response) { + return $user; + } + + $validated = $request->validate([ + 'story_id' => ['required', 'integer'], + 'type' => ['required', 'string', 'in:text,link'], + 'title' => ['required', 'string', 'max:255'], + 'body' => ['nullable', 'string', 'max:10000'], + 'url' => ['nullable', 'string', 'url', 'max:2048'], + ]); + + $story = $this->resolveAccessibleStory($validated['story_id'], $user); + if ($story instanceof Response) { + return $story; + } + + $type = ContextItemType::from($validated['type']); + $metadata = $type === ContextItemType::Text + ? ['body' => $validated['body'] ?? ''] + : ['url' => $validated['url'] ?? '']; + + try { + $item = $writer->createStoryItem($story, [ + 'type' => $type, + 'title' => $validated['title'], + 'metadata' => $metadata, + ], $user); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage()); + } + + return Response::json([ + 'id' => $item->id, + 'project_id' => $item->project_id, + 'story_id' => $item->story_id, + 'type' => $item->type->value, + 'title' => $item->title, + 'metadata' => $item->metadata, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'story_id' => $schema->integer()->description('Story to attach the asset to.')->required(), + 'type' => $schema->string()->enum(['text', 'link'])->description('Asset type: "text" for a note, "link" for a URL.')->required(), + 'title' => $schema->string()->description('Asset title.')->required(), + 'body' => $schema->string()->description('Body text. Required when type is "text".'), + 'url' => $schema->string()->description('URL. Required when type is "link".'), + ]; + } +} diff --git a/app/Mcp/Tools/DeleteContextItemTool.php b/app/Mcp/Tools/DeleteContextItemTool.php new file mode 100644 index 0000000..30fadc9 --- /dev/null +++ b/app/Mcp/Tools/DeleteContextItemTool.php @@ -0,0 +1,51 @@ +resolveUser($request); + if ($user instanceof Response) { + return $user; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer'], + ]); + + $item = ContextItem::query() + ->whereIn('project_id', $user->accessibleProjectIds()) + ->find($validated['id']); + + if (! $item) { + return Response::error('Context item not found.'); + } + + $writer->delete($item, $user); + + return Response::json(['deleted' => true, 'id' => $validated['id']]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('Context item ID to delete.')->required(), + ]; + } +} diff --git a/app/Mcp/Tools/ListContextItemsTool.php b/app/Mcp/Tools/ListContextItemsTool.php new file mode 100644 index 0000000..d8b547b --- /dev/null +++ b/app/Mcp/Tools/ListContextItemsTool.php @@ -0,0 +1,74 @@ +resolveUser($request); + if ($user instanceof Response) { + return $user; + } + + $validated = $request->validate([ + 'project_id' => ['nullable', 'integer'], + 'story_id' => ['nullable', 'integer'], + ]); + + if (! empty($validated['story_id'])) { + $story = $this->resolveAccessibleStory($validated['story_id'], $user); + if ($story instanceof Response) { + return $story; + } + + $items = ContextItem::query() + ->where('story_id', $story->id) + ->orderByDesc('id') + ->get(); + } elseif (! empty($validated['project_id'])) { + $project = $this->resolveAccessibleProject($validated['project_id'], $user); + if ($project instanceof Response) { + return $project; + } + + $items = ContextItem::query() + ->where('project_id', $project->id) + ->whereNull('story_id') + ->orderByDesc('id') + ->get(); + } else { + return Response::error('Provide either project_id or story_id.'); + } + + return Response::json($items->map(fn ($item) => [ + 'id' => $item->id, + 'project_id' => $item->project_id, + 'story_id' => $item->story_id, + 'type' => $item->type->value, + 'title' => $item->title, + 'metadata' => $item->metadata, + ])->all()); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'project_id' => $schema->integer()->description('List project-level assets (no story scope). Mutually exclusive with story_id.'), + 'story_id' => $schema->integer()->description('List story-scoped assets only. Mutually exclusive with project_id.'), + ]; + } +} diff --git a/app/Mcp/Tools/UpdateContextItemTool.php b/app/Mcp/Tools/UpdateContextItemTool.php new file mode 100644 index 0000000..359fe76 --- /dev/null +++ b/app/Mcp/Tools/UpdateContextItemTool.php @@ -0,0 +1,80 @@ +resolveUser($request); + if ($user instanceof Response) { + return $user; + } + + $validated = $request->validate([ + 'id' => ['required', 'integer'], + 'title' => ['nullable', 'string', 'max:255'], + 'body' => ['nullable', 'string', 'max:10000'], + ]); + + $item = ContextItem::query() + ->whereIn('project_id', $user->accessibleProjectIds()) + ->find($validated['id']); + + if (! $item) { + return Response::error('Context item not found.'); + } + + $changes = []; + if (isset($validated['title'])) { + $changes['title'] = $validated['title']; + } + if (isset($validated['body']) && $item->type === ContextItemType::Text) { + $changes['metadata'] = ['body' => $validated['body']]; + } + + if (empty($changes)) { + return Response::error('Nothing to update. Provide title and/or body.'); + } + + try { + $item = $writer->update($item, $changes, $user); + } catch (InvalidArgumentException $e) { + return Response::error($e->getMessage()); + } + + return Response::json([ + 'id' => $item->id, + 'project_id' => $item->project_id, + 'story_id' => $item->story_id, + 'type' => $item->type->value, + 'title' => $item->title, + 'metadata' => $item->metadata, + ]); + } + + public function schema(JsonSchema $schema): array + { + return [ + 'id' => $schema->integer()->description('Context item ID to update.')->required(), + 'title' => $schema->string()->description('New title.'), + 'body' => $schema->string()->description('New body text. Only applies to text-type assets.'), + ]; + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 982c2c5..6d369f8 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -7,6 +7,8 @@ return Application::configure(basePath: dirname(__DIR__)) ->withRouting( web: __DIR__.'/../routes/web.php', + api: __DIR__.'/../routes/api.php', + apiPrefix: 'api/v1', commands: __DIR__.'/../routes/console.php', health: '/up', ) diff --git a/config/specify.php b/config/specify.php index e783bf1..435dd96 100644 --- a/config/specify.php +++ b/config/specify.php @@ -83,6 +83,7 @@ 'mcp' => [ 'user_email' => env('MCP_USER_EMAIL'), + 'api_key' => env('SPECIFY_API_KEY'), ], /* diff --git a/routes/api.php b/routes/api.php new file mode 100644 index 0000000..d7af6d9 --- /dev/null +++ b/routes/api.php @@ -0,0 +1,9 @@ +group(function () { + Route::post('assets/upload', ContextAssetUploadController::class)->name('api.assets.upload'); +}); From 7f32d8302aa773b94682570e75e4293e16e7ed65 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 12:59:47 +0200 Subject: [PATCH 3/5] Fix import ordering in SpecifyServer (pint) Co-Authored-By: Claude Opus 4.7 (1M context) --- app/Mcp/Servers/SpecifyServer.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Mcp/Servers/SpecifyServer.php b/app/Mcp/Servers/SpecifyServer.php index b7888b3..37d6a24 100644 --- a/app/Mcp/Servers/SpecifyServer.php +++ b/app/Mcp/Servers/SpecifyServer.php @@ -3,12 +3,9 @@ namespace App\Mcp\Servers; use App\Mcp\Tools\AddAcceptanceCriterionTool; +use App\Mcp\Tools\AddGithubRepoToProjectTool; use App\Mcp\Tools\AddProjectAssetTool; use App\Mcp\Tools\AddStoryAssetTool; -use App\Mcp\Tools\DeleteContextItemTool; -use App\Mcp\Tools\ListContextItemsTool; -use App\Mcp\Tools\UpdateContextItemTool; -use App\Mcp\Tools\AddGithubRepoToProjectTool; use App\Mcp\Tools\AddStoryDependencyTool; use App\Mcp\Tools\ApprovePlanTool; use App\Mcp\Tools\ApproveStoryTool; @@ -18,6 +15,7 @@ use App\Mcp\Tools\CreateScenarioTool; use App\Mcp\Tools\CreateStoryTool; use App\Mcp\Tools\CurrentContextTool; +use App\Mcp\Tools\DeleteContextItemTool; use App\Mcp\Tools\GenerateTasksTool; use App\Mcp\Tools\GetFeatureTool; use App\Mcp\Tools\GetPlanTool; @@ -27,6 +25,7 @@ use App\Mcp\Tools\GetStoryTool; use App\Mcp\Tools\GetTaskTool; use App\Mcp\Tools\ListActivityTool; +use App\Mcp\Tools\ListContextItemsTool; use App\Mcp\Tools\ListFeaturesTool; use App\Mcp\Tools\ListPlansTool; use App\Mcp\Tools\ListProjectsTool; @@ -49,6 +48,7 @@ use App\Mcp\Tools\SubmitPlanTool; use App\Mcp\Tools\SubmitStoryTool; use App\Mcp\Tools\SwitchProjectTool; +use App\Mcp\Tools\UpdateContextItemTool; use App\Mcp\Tools\UpdateFeatureTool; use App\Mcp\Tools\UpdatePlanTool; use App\Mcp\Tools\UpdateProjectTool; From b47efa54f18105d65289bb79d96e54ad1c010e00 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 13:47:26 +0200 Subject: [PATCH 4/5] Replace custom API key middleware with Laravel Sanctum - Install laravel/sanctum and run personal_access_tokens migration - Add HasApiTokens to User model - API upload route now uses auth:sanctum (Bearer token per user) - Remove AuthenticateApiKey middleware and SPECIFY_API_KEY config key Agents call POST /api/v1/assets/upload with a Sanctum token created via php artisan sanctum:token or the user's profile settings. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/ContextAssetUploadController.php | 3 +- app/Http/Middleware/AuthenticateApiKey.php | 31 ------- app/Models/User.php | 3 +- composer.json | 1 + composer.lock | 65 +++++++++++++- config/sanctum.php | 87 +++++++++++++++++++ config/specify.php | 1 - ...30_create_personal_access_tokens_table.php | 33 +++++++ routes/api.php | 3 +- 9 files changed, 189 insertions(+), 38 deletions(-) delete mode 100644 app/Http/Middleware/AuthenticateApiKey.php create mode 100644 config/sanctum.php create mode 100644 database/migrations/2026_05_10_114530_create_personal_access_tokens_table.php diff --git a/app/Http/Controllers/Api/ContextAssetUploadController.php b/app/Http/Controllers/Api/ContextAssetUploadController.php index 61b6189..5bdfeb0 100644 --- a/app/Http/Controllers/Api/ContextAssetUploadController.php +++ b/app/Http/Controllers/Api/ContextAssetUploadController.php @@ -8,7 +8,6 @@ use App\Services\Context\AssetUploader; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; -use Illuminate\Support\Facades\Auth; use InvalidArgumentException; class ContextAssetUploadController extends Controller @@ -22,7 +21,7 @@ public function __invoke(Request $request, AssetUploader $uploader): JsonRespons 'story_id' => ['required_without:project_id', 'nullable', 'integer'], ]); - $user = Auth::user(); + $user = $request->user(); if (! empty($validated['story_id'])) { $story = Story::query()->with('feature')->find($validated['story_id']); diff --git a/app/Http/Middleware/AuthenticateApiKey.php b/app/Http/Middleware/AuthenticateApiKey.php deleted file mode 100644 index 88077ae..0000000 --- a/app/Http/Middleware/AuthenticateApiKey.php +++ /dev/null @@ -1,31 +0,0 @@ -bearerToken() !== $configured) { - return response()->json(['error' => 'Unauthorized.'], 401); - } - - $email = config('specify.mcp.user_email'); - $user = $email ? User::query()->where('email', $email)->first() : null; - - if (! $user) { - return response()->json(['error' => 'No acting user configured. Set MCP_USER_EMAIL.'], 403); - } - - auth()->setUser($user); - - return $next($request); - } -} diff --git a/app/Models/User.php b/app/Models/User.php index 3fdb4a9..a6cee27 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -17,6 +17,7 @@ use Illuminate\Support\Str; use InvalidArgumentException; use Laravel\Fortify\TwoFactorAuthenticatable; +use Laravel\Sanctum\HasApiTokens; #[Fillable(['name', 'email', 'password', 'current_team_id', 'current_project_id', 'github_id', 'avatar_url', 'github_token', 'github_refresh_token', 'github_token_expires_at', 'github_scopes', 'ai_provider'])] /** @@ -30,7 +31,7 @@ class User extends Authenticatable { /** @use HasFactory */ - use HasFactory, Notifiable, TwoFactorAuthenticatable; + use HasApiTokens, HasFactory, Notifiable, TwoFactorAuthenticatable; protected function casts(): array { diff --git a/composer.json b/composer.json index 8c1102a..d83dfd5 100644 --- a/composer.json +++ b/composer.json @@ -13,6 +13,7 @@ "laravel/ai": "^0.6.4", "laravel/fortify": "^1.34", "laravel/framework": "^13.0", + "laravel/sanctum": "^4.3", "laravel/socialite": "^5.27", "laravel/tinker": "^3.0", "livewire/flux": "^2.13.1", diff --git a/composer.lock b/composer.lock index bd84b6b..3ff2642 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "a64b1204421a18698ccc131f867b506a", + "content-hash": "35b28054d6fefcadcb89277e6cb42abd", "packages": [ { "name": "aws/aws-crt-php", @@ -1904,6 +1904,69 @@ }, "time": "2026-04-20T16:07:33+00:00" }, + { + "name": "laravel/sanctum", + "version": "v4.3.2", + "source": { + "type": "git", + "url": "https://github.com/laravel/sanctum.git", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/laravel/sanctum/zipball/2a9bccc18e9907808e0018dd15fa643937886b1e", + "reference": "2a9bccc18e9907808e0018dd15fa643937886b1e", + "shasum": "" + }, + "require": { + "ext-json": "*", + "illuminate/console": "^11.0|^12.0|^13.0", + "illuminate/contracts": "^11.0|^12.0|^13.0", + "illuminate/database": "^11.0|^12.0|^13.0", + "illuminate/support": "^11.0|^12.0|^13.0", + "php": "^8.2", + "symfony/console": "^7.0|^8.0" + }, + "require-dev": { + "mockery/mockery": "^1.6", + "orchestra/testbench": "^9.15|^10.8|^11.0", + "phpstan/phpstan": "^1.10" + }, + "type": "library", + "extra": { + "laravel": { + "providers": [ + "Laravel\\Sanctum\\SanctumServiceProvider" + ] + } + }, + "autoload": { + "psr-4": { + "Laravel\\Sanctum\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Taylor Otwell", + "email": "taylor@laravel.com" + } + ], + "description": "Laravel Sanctum provides a featherweight authentication system for SPAs and simple APIs.", + "keywords": [ + "auth", + "laravel", + "sanctum" + ], + "support": { + "issues": "https://github.com/laravel/sanctum/issues", + "source": "https://github.com/laravel/sanctum" + }, + "time": "2026-04-30T11:46:25+00:00" + }, { "name": "laravel/serializable-closure", "version": "v2.0.13", diff --git a/config/sanctum.php b/config/sanctum.php new file mode 100644 index 0000000..cde73cf --- /dev/null +++ b/config/sanctum.php @@ -0,0 +1,87 @@ + explode(',', env('SANCTUM_STATEFUL_DOMAINS', sprintf( + '%s%s', + 'localhost,localhost:3000,127.0.0.1,127.0.0.1:8000,::1', + Sanctum::currentApplicationUrlWithPort(), + // Sanctum::currentRequestHost(), + ))), + + /* + |-------------------------------------------------------------------------- + | Sanctum Guards + |-------------------------------------------------------------------------- + | + | This array contains the authentication guards that will be checked when + | Sanctum is trying to authenticate a request. If none of these guards + | are able to authenticate the request, Sanctum will use the bearer + | token that's present on an incoming request for authentication. + | + */ + + 'guard' => ['web'], + + /* + |-------------------------------------------------------------------------- + | Expiration Minutes + |-------------------------------------------------------------------------- + | + | This value controls the number of minutes until an issued token will be + | considered expired. This will override any values set in the token's + | "expires_at" attribute, but first-party sessions are not affected. + | + */ + + 'expiration' => null, + + /* + |-------------------------------------------------------------------------- + | Token Prefix + |-------------------------------------------------------------------------- + | + | Sanctum can prefix new tokens in order to take advantage of numerous + | security scanning initiatives maintained by open source platforms + | that notify developers if they commit tokens into repositories. + | + | See: https://docs.github.com/en/code-security/secret-scanning/about-secret-scanning + | + */ + + 'token_prefix' => env('SANCTUM_TOKEN_PREFIX', ''), + + /* + |-------------------------------------------------------------------------- + | Sanctum Middleware + |-------------------------------------------------------------------------- + | + | When authenticating your first-party SPA with Sanctum you may need to + | customize some of the middleware Sanctum uses while processing the + | request. You may change the middleware listed below as required. + | + */ + + 'middleware' => [ + 'authenticate_session' => AuthenticateSession::class, + 'encrypt_cookies' => EncryptCookies::class, + 'validate_csrf_token' => ValidateCsrfToken::class, + ], + +]; diff --git a/config/specify.php b/config/specify.php index 435dd96..e783bf1 100644 --- a/config/specify.php +++ b/config/specify.php @@ -83,7 +83,6 @@ 'mcp' => [ 'user_email' => env('MCP_USER_EMAIL'), - 'api_key' => env('SPECIFY_API_KEY'), ], /* diff --git a/database/migrations/2026_05_10_114530_create_personal_access_tokens_table.php b/database/migrations/2026_05_10_114530_create_personal_access_tokens_table.php new file mode 100644 index 0000000..40ff706 --- /dev/null +++ b/database/migrations/2026_05_10_114530_create_personal_access_tokens_table.php @@ -0,0 +1,33 @@ +id(); + $table->morphs('tokenable'); + $table->text('name'); + $table->string('token', 64)->unique(); + $table->text('abilities')->nullable(); + $table->timestamp('last_used_at')->nullable(); + $table->timestamp('expires_at')->nullable()->index(); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('personal_access_tokens'); + } +}; diff --git a/routes/api.php b/routes/api.php index d7af6d9..a72dfad 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,9 +1,8 @@ group(function () { +Route::middleware('auth:sanctum')->group(function () { Route::post('assets/upload', ContextAssetUploadController::class)->name('api.assets.upload'); }); From 07529b678b93538c2c9386de1afef7f67c3b37f4 Mon Sep 17 00:00:00 2001 From: Marlin Forbes Date: Sun, 10 May 2026 13:59:01 +0200 Subject: [PATCH 5/5] Address Copilot review on context asset MCP tools - AddProjectAssetTool / AddStoryAssetTool: add required_if validation for body (text) and url (link); catch ValidationException and return Response::error so callers get a clean error instead of an exception - ListContextItemsTool: reject requests that supply both project_id and story_id at once instead of silently preferring story_id - UpdateContextItemTool: add url field support for link assets; fix description to match actual behaviour - ContextAssetUploadController: add prohibited_with to prevent both project_id and story_id being supplied simultaneously - Add ContextItemMcpToolsTest covering list/add/update/delete including access control and ADR-0015 story-approval-reopen semantics Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Api/ContextAssetUploadController.php | 4 +- app/Mcp/Tools/AddProjectAssetTool.php | 19 +- app/Mcp/Tools/AddStoryAssetTool.php | 19 +- app/Mcp/Tools/ListContextItemsTool.php | 4 + app/Mcp/Tools/UpdateContextItemTool.php | 6 +- tests/Feature/ContextItemMcpToolsTest.php | 235 ++++++++++++++++++ 6 files changed, 270 insertions(+), 17 deletions(-) create mode 100644 tests/Feature/ContextItemMcpToolsTest.php diff --git a/app/Http/Controllers/Api/ContextAssetUploadController.php b/app/Http/Controllers/Api/ContextAssetUploadController.php index 5bdfeb0..d117bf8 100644 --- a/app/Http/Controllers/Api/ContextAssetUploadController.php +++ b/app/Http/Controllers/Api/ContextAssetUploadController.php @@ -17,8 +17,8 @@ public function __invoke(Request $request, AssetUploader $uploader): JsonRespons $validated = $request->validate([ 'file' => ['required', 'file'], 'title' => ['nullable', 'string', 'max:255'], - 'project_id' => ['required_without:story_id', 'nullable', 'integer'], - 'story_id' => ['required_without:project_id', 'nullable', 'integer'], + 'project_id' => ['required_without:story_id', 'nullable', 'integer', 'prohibited_with:story_id'], + 'story_id' => ['required_without:project_id', 'nullable', 'integer', 'prohibited_with:project_id'], ]); $user = $request->user(); diff --git a/app/Mcp/Tools/AddProjectAssetTool.php b/app/Mcp/Tools/AddProjectAssetTool.php index 1befbec..0f26981 100644 --- a/app/Mcp/Tools/AddProjectAssetTool.php +++ b/app/Mcp/Tools/AddProjectAssetTool.php @@ -6,6 +6,7 @@ use App\Mcp\Concerns\ResolvesProjectAccess; use App\Services\Context\ContextItemWriter; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Validation\ValidationException; use InvalidArgumentException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -26,13 +27,17 @@ public function handle(Request $request, ContextItemWriter $writer): Response return $user; } - $validated = $request->validate([ - 'project_id' => ['required', 'integer'], - 'type' => ['required', 'string', 'in:text,link'], - 'title' => ['required', 'string', 'max:255'], - 'body' => ['nullable', 'string', 'max:10000'], - 'url' => ['nullable', 'string', 'url', 'max:2048'], - ]); + try { + $validated = $request->validate([ + 'project_id' => ['required', 'integer'], + 'type' => ['required', 'string', 'in:text,link'], + 'title' => ['required', 'string', 'max:255'], + 'body' => ['required_if:type,text', 'nullable', 'string', 'max:10000'], + 'url' => ['required_if:type,link', 'nullable', 'string', 'url', 'max:2048'], + ]); + } catch (ValidationException $e) { + return Response::error(implode(' ', array_merge(...array_values($e->errors())))); + } $project = $this->resolveAccessibleProject($validated['project_id'], $user); if ($project instanceof Response) { diff --git a/app/Mcp/Tools/AddStoryAssetTool.php b/app/Mcp/Tools/AddStoryAssetTool.php index 3d6179f..234c0ce 100644 --- a/app/Mcp/Tools/AddStoryAssetTool.php +++ b/app/Mcp/Tools/AddStoryAssetTool.php @@ -6,6 +6,7 @@ use App\Mcp\Concerns\ResolvesProjectAccess; use App\Services\Context\ContextItemWriter; use Illuminate\Contracts\JsonSchema\JsonSchema; +use Illuminate\Validation\ValidationException; use InvalidArgumentException; use Laravel\Mcp\Request; use Laravel\Mcp\Response; @@ -26,13 +27,17 @@ public function handle(Request $request, ContextItemWriter $writer): Response return $user; } - $validated = $request->validate([ - 'story_id' => ['required', 'integer'], - 'type' => ['required', 'string', 'in:text,link'], - 'title' => ['required', 'string', 'max:255'], - 'body' => ['nullable', 'string', 'max:10000'], - 'url' => ['nullable', 'string', 'url', 'max:2048'], - ]); + try { + $validated = $request->validate([ + 'story_id' => ['required', 'integer'], + 'type' => ['required', 'string', 'in:text,link'], + 'title' => ['required', 'string', 'max:255'], + 'body' => ['required_if:type,text', 'nullable', 'string', 'max:10000'], + 'url' => ['required_if:type,link', 'nullable', 'string', 'url', 'max:2048'], + ]); + } catch (ValidationException $e) { + return Response::error(implode(' ', array_merge(...array_values($e->errors())))); + } $story = $this->resolveAccessibleStory($validated['story_id'], $user); if ($story instanceof Response) { diff --git a/app/Mcp/Tools/ListContextItemsTool.php b/app/Mcp/Tools/ListContextItemsTool.php index d8b547b..f743354 100644 --- a/app/Mcp/Tools/ListContextItemsTool.php +++ b/app/Mcp/Tools/ListContextItemsTool.php @@ -29,6 +29,10 @@ public function handle(Request $request): Response 'story_id' => ['nullable', 'integer'], ]); + if (! empty($validated['story_id']) && ! empty($validated['project_id'])) { + return Response::error('Provide either project_id or story_id, not both.'); + } + if (! empty($validated['story_id'])) { $story = $this->resolveAccessibleStory($validated['story_id'], $user); if ($story instanceof Response) { diff --git a/app/Mcp/Tools/UpdateContextItemTool.php b/app/Mcp/Tools/UpdateContextItemTool.php index 359fe76..d9da86e 100644 --- a/app/Mcp/Tools/UpdateContextItemTool.php +++ b/app/Mcp/Tools/UpdateContextItemTool.php @@ -13,7 +13,7 @@ use Laravel\Mcp\Server\Attributes\Description; use Laravel\Mcp\Server\Tool; -#[Description('Update the title or body of an existing text or link context asset. File assets can only have their title changed.')] +#[Description('Update an existing context asset. Text assets: update title and/or body. Link assets: update title and/or url. File assets: title only.')] class UpdateContextItemTool extends Tool { use ResolvesProjectAccess; @@ -31,6 +31,7 @@ public function handle(Request $request, ContextItemWriter $writer): Response 'id' => ['required', 'integer'], 'title' => ['nullable', 'string', 'max:255'], 'body' => ['nullable', 'string', 'max:10000'], + 'url' => ['nullable', 'string', 'url', 'max:2048'], ]); $item = ContextItem::query() @@ -47,6 +48,8 @@ public function handle(Request $request, ContextItemWriter $writer): Response } if (isset($validated['body']) && $item->type === ContextItemType::Text) { $changes['metadata'] = ['body' => $validated['body']]; + } elseif (isset($validated['url']) && $item->type === ContextItemType::Link) { + $changes['metadata'] = array_merge($item->metadata ?? [], ['url' => $validated['url']]); } if (empty($changes)) { @@ -75,6 +78,7 @@ public function schema(JsonSchema $schema): array 'id' => $schema->integer()->description('Context item ID to update.')->required(), 'title' => $schema->string()->description('New title.'), 'body' => $schema->string()->description('New body text. Only applies to text-type assets.'), + 'url' => $schema->string()->description('New URL. Only applies to link-type assets.'), ]; } } diff --git a/tests/Feature/ContextItemMcpToolsTest.php b/tests/Feature/ContextItemMcpToolsTest.php new file mode 100644 index 0000000..070beb8 --- /dev/null +++ b/tests/Feature/ContextItemMcpToolsTest.php @@ -0,0 +1,235 @@ +create(); + $team = Team::factory()->for($workspace)->create(); + $user = User::factory()->create(); + $team->addMember($user, TeamRole::Admin); + $project = Project::factory()->for($team)->create(); + $feature = Feature::factory()->for($project)->create(); + $story = Story::factory()->for($feature)->create(['status' => StoryStatus::Approved, 'revision' => 1]); + + return compact('user', 'project', 'story'); +} + +// ── list-context-items ────────────────────────────────────────────────────── + +test('list-context-items returns project assets', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $item = ContextItem::factory()->for($project)->forText('body')->create(['title' => 'Spec']); + $this->actingAs($user); + + $response = app(ListContextItemsTool::class)->handle(new Request([ + 'project_id' => $project->id, + ])); + + expect($response->isError())->toBeFalse(); + $data = json_decode((string) $response->content(), true); + expect(collect($data)->firstWhere('id', $item->id)['title'])->toBe('Spec'); +}); + +test('list-context-items returns story assets only', function () { + ['user' => $user, 'project' => $project, 'story' => $story] = contextMcpScene(); + ContextItem::factory()->for($project)->for($story)->forText('body')->create(['title' => 'Story note']); + ContextItem::factory()->for($project)->forText('body')->create(['title' => 'Project note']); + $this->actingAs($user); + + $response = app(ListContextItemsTool::class)->handle(new Request([ + 'story_id' => $story->id, + ])); + + $data = json_decode((string) $response->content(), true); + expect(collect($data)->pluck('title')->all())->toBe(['Story note']); +}); + +test('list-context-items rejects both ids at once', function () { + ['user' => $user, 'project' => $project, 'story' => $story] = contextMcpScene(); + $this->actingAs($user); + + $response = app(ListContextItemsTool::class)->handle(new Request([ + 'project_id' => $project->id, + 'story_id' => $story->id, + ])); + + expect($response->isError())->toBeTrue(); +}); + +// ── add-project-asset ─────────────────────────────────────────────────────── + +test('add-project-asset creates text item', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $this->actingAs($user); + + $response = app(AddProjectAssetTool::class)->handle(new Request([ + 'project_id' => $project->id, + 'type' => 'text', + 'title' => 'Style guide', + 'body' => 'Use Oxford commas.', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeFalse(); + $item = $project->contextItems()->first(); + expect($item->type)->toBe(ContextItemType::Text); + expect($item->story_id)->toBeNull(); +}); + +test('add-project-asset creates link item', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $this->actingAs($user); + + $response = app(AddProjectAssetTool::class)->handle(new Request([ + 'project_id' => $project->id, + 'type' => 'link', + 'title' => 'Figma', + 'url' => 'https://figma.com/x', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeFalse(); + $item = $project->contextItems()->first(); + expect($item->type)->toBe(ContextItemType::Link); + expect($item->metadata['url'])->toBe('https://figma.com/x'); +}); + +test('add-project-asset requires body for text type', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $this->actingAs($user); + + $response = app(AddProjectAssetTool::class)->handle(new Request([ + 'project_id' => $project->id, + 'type' => 'text', + 'title' => 'Incomplete', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeTrue(); +}); + +// ── add-story-asset ───────────────────────────────────────────────────────── + +test('add-story-asset auto-includes and bumps revision', function () { + ['user' => $user, 'story' => $story] = contextMcpScene(); + $before = $story->fresh()->revision; + $this->actingAs($user); + + $response = app(AddStoryAssetTool::class)->handle(new Request([ + 'story_id' => $story->id, + 'type' => 'text', + 'title' => 'Story note', + 'body' => 'Important detail.', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeFalse(); + $item = $story->ownedContextItems()->first(); + expect($story->fresh()->revision)->toBe($before + 1); + expect($story->includedContextItems()->whereKey($item->id)->exists())->toBeTrue(); +}); + +test('add-story-asset requires url for link type', function () { + ['user' => $user, 'story' => $story] = contextMcpScene(); + $this->actingAs($user); + + $response = app(AddStoryAssetTool::class)->handle(new Request([ + 'story_id' => $story->id, + 'type' => 'link', + 'title' => 'Missing URL', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeTrue(); +}); + +// ── update-context-item ───────────────────────────────────────────────────── + +test('update-context-item updates title and body for text', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $item = ContextItem::factory()->for($project)->forText('old body')->create(['title' => 'Old']); + $this->actingAs($user); + + $response = app(UpdateContextItemTool::class)->handle(new Request([ + 'id' => $item->id, + 'title' => 'New', + 'body' => 'new body', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeFalse(); + expect($item->fresh()->title)->toBe('New'); + expect($item->fresh()->metadata['body'])->toBe('new body'); +}); + +test('update-context-item updates url for link', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $item = ContextItem::factory()->for($project)->forLink('https://old.example.com')->create(['title' => 'Link']); + $this->actingAs($user); + + $response = app(UpdateContextItemTool::class)->handle(new Request([ + 'id' => $item->id, + 'url' => 'https://new.example.com', + ]), app(ContextItemWriter::class)); + + expect($response->isError())->toBeFalse(); + expect($item->fresh()->metadata['url'])->toBe('https://new.example.com'); +}); + +// ── delete-context-item ───────────────────────────────────────────────────── + +test('delete-context-item removes the row', function () { + ['user' => $user, 'project' => $project] = contextMcpScene(); + $item = ContextItem::factory()->for($project)->forText('x')->create(); + $this->actingAs($user); + + $response = app(DeleteContextItemTool::class)->handle( + new Request(['id' => $item->id]), + app(ContextItemWriter::class), + ); + + expect($response->isError())->toBeFalse(); + expect(ContextItem::query()->whereKey($item->id)->exists())->toBeFalse(); +}); + +test('delete-context-item on story asset bumps revision', function () { + ['user' => $user, 'project' => $project, 'story' => $story] = contextMcpScene(); + $item = ContextItem::factory()->for($project)->for($story)->forText('x')->create(); + $before = $story->fresh()->revision; + $this->actingAs($user); + + app(DeleteContextItemTool::class)->handle( + new Request(['id' => $item->id]), + app(ContextItemWriter::class), + ); + + expect($story->fresh()->revision)->toBe($before + 1); +}); + +test('delete-context-item returns error for inaccessible item', function () { + ['user' => $user] = contextMcpScene(); + $other = Project::factory()->for(Team::factory()->for(Workspace::factory()->create())->create())->create(); + $item = ContextItem::factory()->for($other)->forText('x')->create(); + $this->actingAs($user); + + $response = app(DeleteContextItemTool::class)->handle( + new Request(['id' => $item->id]), + app(ContextItemWriter::class), + ); + + expect($response->isError())->toBeTrue(); +});