mcps and skills
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers;
|
||||
|
||||
use App\Filament\Resources\McpServers\Pages\CreateMcpServer;
|
||||
use App\Filament\Resources\McpServers\Pages\EditMcpServer;
|
||||
use App\Filament\Resources\McpServers\Pages\ListMcpServers;
|
||||
use App\Filament\Resources\McpServers\Schemas\McpServerForm;
|
||||
use App\Filament\Resources\McpServers\Tables\McpServersTable;
|
||||
use App\Models\McpServer;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class McpServerResource extends Resource
|
||||
{
|
||||
protected static ?string $model = McpServer::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedServerStack;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Tools';
|
||||
|
||||
protected static ?string $navigationLabel = 'MCP Servers';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return McpServerForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return McpServersTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListMcpServers::route('/'),
|
||||
'create' => CreateMcpServer::route('/create'),
|
||||
'edit' => EditMcpServer::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers\Pages;
|
||||
|
||||
use App\Filament\Resources\McpServers\McpServerResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateMcpServer extends CreateRecord
|
||||
{
|
||||
protected static string $resource = McpServerResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers\Pages;
|
||||
|
||||
use App\Filament\Resources\McpServers\McpServerResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditMcpServer extends EditRecord
|
||||
{
|
||||
protected static string $resource = McpServerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers\Pages;
|
||||
|
||||
use App\Filament\Resources\McpServers\McpServerResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListMcpServers extends ListRecords
|
||||
{
|
||||
protected static string $resource = McpServerResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers\Schemas;
|
||||
|
||||
use App\Models\McpServer;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class McpServerForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
Section::make('Details')
|
||||
->columnSpan(2)
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(McpServer::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /mcp-servers/your-slug'),
|
||||
Textarea::make('description')
|
||||
->required()
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('url')
|
||||
->label('Repository / homepage URL')
|
||||
->url()
|
||||
->required(),
|
||||
TextInput::make('docs_url')
|
||||
->label('Documentation URL')
|
||||
->url(),
|
||||
TextInput::make('logo_url')
|
||||
->label('Logo URL')
|
||||
->url(),
|
||||
Textarea::make('install_command')
|
||||
->label('Install command')
|
||||
->rows(2)
|
||||
->helperText('e.g. npx -y @modelcontextprotocol/server-filesystem /path')
|
||||
->columnSpanFull(),
|
||||
Textarea::make('config_example')
|
||||
->label('Example client configuration')
|
||||
->rows(6)
|
||||
->helperText('The JSON snippet a user pastes into their MCP client config')
|
||||
->columnSpanFull(),
|
||||
TagsInput::make('capabilities')
|
||||
->placeholder('Add a capability and press enter')
|
||||
->helperText('e.g. Read files, Write files, List directories')
|
||||
->columnSpanFull(),
|
||||
TagsInput::make('tags')
|
||||
->placeholder('Add a tag and press enter')
|
||||
->helperText('e.g. official, read-only, requires-api-key')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Classification')
|
||||
->columnSpan(1)
|
||||
->components([
|
||||
Select::make('category')
|
||||
->options(config('mcp_servers.categories'))
|
||||
->required()
|
||||
->searchable(),
|
||||
Select::make('transport')
|
||||
->options(config('mcp_servers.transports')),
|
||||
TextInput::make('language')
|
||||
->helperText('e.g. TypeScript, Python'),
|
||||
]),
|
||||
Section::make('Moderation')
|
||||
->columnSpan(3)
|
||||
->columns(4)
|
||||
->components([
|
||||
Toggle::make('approved')
|
||||
->helperText('Only approved servers are shown publicly.')
|
||||
->default(false),
|
||||
Toggle::make('featured')
|
||||
->helperText('Highlight this server.')
|
||||
->default(false),
|
||||
Select::make('user_id')
|
||||
->label('Submitted by')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TextInput::make('views')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
TextInput::make('clicks')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\McpServers\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class McpServersTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('logo_url')
|
||||
->label(''),
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('category')
|
||||
->formatStateUsing(fn (string $state) => config("mcp_servers.categories.{$state}", $state))
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('transport')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('language')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('views')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('clicks')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
IconColumn::make('featured')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
IconColumn::make('approved')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->label('Submitted by')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('category')
|
||||
->options(config('mcp_servers.categories')),
|
||||
TernaryFilter::make('approved'),
|
||||
TernaryFilter::make('featured'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills\Pages;
|
||||
|
||||
use App\Filament\Resources\Skills\SkillResource;
|
||||
use Filament\Resources\Pages\CreateRecord;
|
||||
|
||||
class CreateSkill extends CreateRecord
|
||||
{
|
||||
protected static string $resource = SkillResource::class;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills\Pages;
|
||||
|
||||
use App\Filament\Resources\Skills\SkillResource;
|
||||
use Filament\Actions\DeleteAction;
|
||||
use Filament\Resources\Pages\EditRecord;
|
||||
|
||||
class EditSkill extends EditRecord
|
||||
{
|
||||
protected static string $resource = SkillResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
DeleteAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills\Pages;
|
||||
|
||||
use App\Filament\Resources\Skills\SkillResource;
|
||||
use Filament\Actions\CreateAction;
|
||||
use Filament\Resources\Pages\ListRecords;
|
||||
|
||||
class ListSkills extends ListRecords
|
||||
{
|
||||
protected static string $resource = SkillResource::class;
|
||||
|
||||
protected function getHeaderActions(): array
|
||||
{
|
||||
return [
|
||||
CreateAction::make(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills\Schemas;
|
||||
|
||||
use App\Models\Skill;
|
||||
use Filament\Forms\Components\Select;
|
||||
use Filament\Forms\Components\Textarea;
|
||||
use Filament\Forms\Components\TagsInput;
|
||||
use Filament\Forms\Components\TextInput;
|
||||
use Filament\Forms\Components\Toggle;
|
||||
use Filament\Schemas\Components\Section;
|
||||
use Filament\Schemas\Schema;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SkillForm
|
||||
{
|
||||
public static function configure(Schema $schema): Schema
|
||||
{
|
||||
return $schema
|
||||
->columns(3)
|
||||
->components([
|
||||
Section::make('Details')
|
||||
->columnSpan(2)
|
||||
->components([
|
||||
TextInput::make('name')
|
||||
->required()
|
||||
->live(onBlur: true)
|
||||
->afterStateUpdated(function (string $operation, $state, callable $set) {
|
||||
if ($operation === 'create') {
|
||||
$set('slug', Str::slug($state));
|
||||
}
|
||||
}),
|
||||
TextInput::make('slug')
|
||||
->required()
|
||||
->unique(Skill::class, 'slug', ignoreRecord: true)
|
||||
->helperText('Used in the URL, e.g. /skills/your-slug'),
|
||||
Textarea::make('description')
|
||||
->required()
|
||||
->rows(4)
|
||||
->columnSpanFull(),
|
||||
TextInput::make('url')
|
||||
->label('Repository / homepage URL')
|
||||
->url()
|
||||
->required(),
|
||||
TextInput::make('docs_url')
|
||||
->label('Documentation URL')
|
||||
->url(),
|
||||
TextInput::make('logo_url')
|
||||
->label('Logo URL')
|
||||
->url(),
|
||||
Textarea::make('install_instructions')
|
||||
->label('Install instructions')
|
||||
->rows(2)
|
||||
->helperText('e.g. Copy into ~/.claude/skills/ and restart Claude')
|
||||
->columnSpanFull(),
|
||||
Textarea::make('example_prompt')
|
||||
->label('Example prompt')
|
||||
->rows(2)
|
||||
->helperText('A sample prompt that would trigger or use this skill')
|
||||
->columnSpanFull(),
|
||||
TagsInput::make('tags')
|
||||
->placeholder('Add a tag and press enter')
|
||||
->helperText('e.g. official, anthropic, community')
|
||||
->columnSpanFull(),
|
||||
]),
|
||||
Section::make('Classification')
|
||||
->columnSpan(1)
|
||||
->components([
|
||||
Select::make('category')
|
||||
->options(config('skills.categories'))
|
||||
->required()
|
||||
->searchable(),
|
||||
TextInput::make('requirements')
|
||||
->helperText('e.g. Claude Code, Code Execution tool'),
|
||||
]),
|
||||
Section::make('Moderation')
|
||||
->columnSpan(3)
|
||||
->columns(4)
|
||||
->components([
|
||||
Toggle::make('approved')
|
||||
->helperText('Only approved skills are shown publicly.')
|
||||
->default(false),
|
||||
Toggle::make('featured')
|
||||
->helperText('Highlight this skill.')
|
||||
->default(false),
|
||||
Select::make('user_id')
|
||||
->label('Submitted by')
|
||||
->relationship('user', 'name')
|
||||
->searchable()
|
||||
->preload(),
|
||||
TextInput::make('views')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
TextInput::make('clicks')
|
||||
->numeric()
|
||||
->default(0)
|
||||
->disabled()
|
||||
->dehydrated(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills;
|
||||
|
||||
use App\Filament\Resources\Skills\Pages\CreateSkill;
|
||||
use App\Filament\Resources\Skills\Pages\EditSkill;
|
||||
use App\Filament\Resources\Skills\Pages\ListSkills;
|
||||
use App\Filament\Resources\Skills\Schemas\SkillForm;
|
||||
use App\Filament\Resources\Skills\Tables\SkillsTable;
|
||||
use App\Models\Skill;
|
||||
use BackedEnum;
|
||||
use Filament\Resources\Resource;
|
||||
use Filament\Schemas\Schema;
|
||||
use Filament\Support\Icons\Heroicon;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SkillResource extends Resource
|
||||
{
|
||||
protected static ?string $model = Skill::class;
|
||||
|
||||
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedSparkles;
|
||||
|
||||
protected static string|\UnitEnum|null $navigationGroup = 'Tools';
|
||||
|
||||
protected static ?string $navigationLabel = 'Skills';
|
||||
|
||||
protected static ?string $recordTitleAttribute = 'name';
|
||||
|
||||
public static function form(Schema $schema): Schema
|
||||
{
|
||||
return SkillForm::configure($schema);
|
||||
}
|
||||
|
||||
public static function table(Table $table): Table
|
||||
{
|
||||
return SkillsTable::configure($table);
|
||||
}
|
||||
|
||||
public static function getRelations(): array
|
||||
{
|
||||
return [
|
||||
//
|
||||
];
|
||||
}
|
||||
|
||||
public static function getPages(): array
|
||||
{
|
||||
return [
|
||||
'index' => ListSkills::route('/'),
|
||||
'create' => CreateSkill::route('/create'),
|
||||
'edit' => EditSkill::route('/{record}/edit'),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Filament\Resources\Skills\Tables;
|
||||
|
||||
use Filament\Actions\BulkActionGroup;
|
||||
use Filament\Actions\DeleteBulkAction;
|
||||
use Filament\Actions\EditAction;
|
||||
use Filament\Tables\Columns\IconColumn;
|
||||
use Filament\Tables\Columns\ImageColumn;
|
||||
use Filament\Tables\Columns\TextColumn;
|
||||
use Filament\Tables\Filters\SelectFilter;
|
||||
use Filament\Tables\Filters\TernaryFilter;
|
||||
use Filament\Tables\Table;
|
||||
|
||||
class SkillsTable
|
||||
{
|
||||
public static function configure(Table $table): Table
|
||||
{
|
||||
return $table
|
||||
->columns([
|
||||
ImageColumn::make('logo_url')
|
||||
->label(''),
|
||||
TextColumn::make('name')
|
||||
->searchable()
|
||||
->weight('medium'),
|
||||
TextColumn::make('category')
|
||||
->formatStateUsing(fn (string $state) => config("skills.categories.{$state}", $state))
|
||||
->badge()
|
||||
->sortable(),
|
||||
TextColumn::make('requirements')
|
||||
->placeholder('—'),
|
||||
TextColumn::make('views')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
TextColumn::make('clicks')
|
||||
->numeric()
|
||||
->sortable(),
|
||||
IconColumn::make('featured')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
IconColumn::make('approved')
|
||||
->boolean()
|
||||
->sortable(),
|
||||
TextColumn::make('user.name')
|
||||
->label('Submitted by')
|
||||
->placeholder('—')
|
||||
->searchable(),
|
||||
TextColumn::make('created_at')
|
||||
->dateTime()
|
||||
->sortable()
|
||||
->toggleable(isToggledHiddenByDefault: true),
|
||||
])
|
||||
->defaultSort('created_at', 'desc')
|
||||
->filters([
|
||||
SelectFilter::make('category')
|
||||
->options(config('skills.categories')),
|
||||
TernaryFilter::make('approved'),
|
||||
TernaryFilter::make('featured'),
|
||||
])
|
||||
->recordActions([
|
||||
EditAction::make(),
|
||||
])
|
||||
->toolbarActions([
|
||||
BulkActionGroup::make([
|
||||
DeleteBulkAction::make(),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreMcpServerRequest;
|
||||
use App\Http\Requests\UpdateMcpServerRequest;
|
||||
use App\Http\Resources\McpServerResource;
|
||||
use App\Models\McpServer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class McpServerController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$servers = McpServer::query()
|
||||
->with('user')
|
||||
->when($request->string('category')->toString(), fn ($query, $category) => $query->where('category', $category))
|
||||
->when($request->has('approved'), fn ($query) => $query->where('approved', $request->boolean('approved')))
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return McpServerResource::collection($servers);
|
||||
}
|
||||
|
||||
public function store(StoreMcpServerRequest $request): McpServerResource
|
||||
{
|
||||
$server = McpServer::query()->create([
|
||||
'featured' => false,
|
||||
'approved' => false,
|
||||
...$request->validated(),
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return new McpServerResource($server->refresh()->load('user'));
|
||||
}
|
||||
|
||||
public function show(McpServer $mcpServer): McpServerResource
|
||||
{
|
||||
return new McpServerResource($mcpServer->load('user'));
|
||||
}
|
||||
|
||||
public function update(UpdateMcpServerRequest $request, McpServer $mcpServer): McpServerResource
|
||||
{
|
||||
$mcpServer->update($request->validated());
|
||||
|
||||
return new McpServerResource($mcpServer->load('user'));
|
||||
}
|
||||
|
||||
public function destroy(McpServer $mcpServer): Response
|
||||
{
|
||||
$mcpServer->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\StoreSkillRequest;
|
||||
use App\Http\Requests\UpdateSkillRequest;
|
||||
use App\Http\Resources\SkillResource;
|
||||
use App\Models\Skill;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
use Illuminate\Http\Response;
|
||||
|
||||
class SkillController extends Controller
|
||||
{
|
||||
public function index(Request $request): AnonymousResourceCollection
|
||||
{
|
||||
$skills = Skill::query()
|
||||
->with('user')
|
||||
->when($request->string('category')->toString(), fn ($query, $category) => $query->where('category', $category))
|
||||
->when($request->has('approved'), fn ($query) => $query->where('approved', $request->boolean('approved')))
|
||||
->latest('created_at')
|
||||
->paginate(20);
|
||||
|
||||
return SkillResource::collection($skills);
|
||||
}
|
||||
|
||||
public function store(StoreSkillRequest $request): SkillResource
|
||||
{
|
||||
$skill = Skill::query()->create([
|
||||
'featured' => false,
|
||||
'approved' => false,
|
||||
...$request->validated(),
|
||||
'user_id' => $request->user()->id,
|
||||
]);
|
||||
|
||||
return new SkillResource($skill->refresh()->load('user'));
|
||||
}
|
||||
|
||||
public function show(Skill $skill): SkillResource
|
||||
{
|
||||
return new SkillResource($skill->load('user'));
|
||||
}
|
||||
|
||||
public function update(UpdateSkillRequest $request, Skill $skill): SkillResource
|
||||
{
|
||||
$skill->update($request->validated());
|
||||
|
||||
return new SkillResource($skill->load('user'));
|
||||
}
|
||||
|
||||
public function destroy(Skill $skill): Response
|
||||
{
|
||||
$skill->delete();
|
||||
|
||||
return response()->noContent();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\McpServer;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class McpServerController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$category = $request->string('category')->toString();
|
||||
$search = $request->string('q')->toString();
|
||||
|
||||
$servers = McpServer::approved()
|
||||
->when($category, fn ($query) => $query->where('category', $category))
|
||||
->when($search, fn ($query) => $query->where('name', 'like', "%{$search}%"))
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('mcp-servers.index', [
|
||||
'servers' => $servers,
|
||||
'categories' => config('mcp_servers.categories'),
|
||||
'activeCategory' => $category,
|
||||
'search' => $search,
|
||||
'title' => 'MCP Servers Directory — '.config('app.name'),
|
||||
'description' => 'Discover Model Context Protocol servers, with docs, install instructions, and config examples.',
|
||||
'canonical' => route('mcp-servers.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(string $category): View
|
||||
{
|
||||
abort_unless(array_key_exists($category, config('mcp_servers.categories')), 404);
|
||||
|
||||
$servers = McpServer::approved()
|
||||
->where('category', $category)
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12);
|
||||
|
||||
$label = config("mcp_servers.categories.{$category}");
|
||||
|
||||
return view('mcp-servers.category', [
|
||||
'servers' => $servers,
|
||||
'categories' => config('mcp_servers.categories'),
|
||||
'category' => $category,
|
||||
'label' => $label,
|
||||
'title' => $label.' MCP Servers — '.config('app.name'),
|
||||
'description' => "MCP servers for {$label}.",
|
||||
'canonical' => route('mcp-servers.category', $category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(McpServer $mcpServer): View
|
||||
{
|
||||
abort_unless($mcpServer->approved, 404);
|
||||
|
||||
$mcpServer->increment('views');
|
||||
|
||||
$related = McpServer::approved()
|
||||
->where('category', $mcpServer->category)
|
||||
->whereKeyNot($mcpServer->id)
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('mcp-servers.show', [
|
||||
'server' => $mcpServer,
|
||||
'related' => $related,
|
||||
'title' => $mcpServer->name.' — MCP Servers — '.config('app.name'),
|
||||
'description' => $this->metaDescription($mcpServer->description),
|
||||
'canonical' => route('mcp-servers.show', $mcpServer),
|
||||
'image' => $mcpServer->logo_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function visit(McpServer $mcpServer): RedirectResponse
|
||||
{
|
||||
abort_unless($mcpServer->approved, 404);
|
||||
|
||||
$mcpServer->increment('clicks');
|
||||
|
||||
return redirect()->away($mcpServer->url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate to ~155 chars without cutting a word in half. Google appends
|
||||
* its own ellipsis when it truncates in search results, so we don't add one.
|
||||
*/
|
||||
private function metaDescription(string $description, int $limit = 155): string
|
||||
{
|
||||
if (Str::length($description) <= $limit) {
|
||||
return $description;
|
||||
}
|
||||
|
||||
return Str::of($description)->limit($limit, '')->beforeLast(' ')->toString();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,10 @@
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Category;
|
||||
use App\Models\McpServer;
|
||||
use App\Models\Page;
|
||||
use App\Models\Post;
|
||||
use App\Models\Skill;
|
||||
use App\Models\Tag;
|
||||
use App\Models\Tool;
|
||||
use Illuminate\Http\Response;
|
||||
@@ -18,6 +20,10 @@ class SitemapController extends Controller
|
||||
$tags = Tag::all(['slug', 'updated_at']);
|
||||
$tools = Tool::approved()->get(['slug', 'updated_at']);
|
||||
$toolCategories = array_keys(config('tools.categories'));
|
||||
$mcpServers = McpServer::approved()->get(['slug', 'updated_at']);
|
||||
$mcpServerCategories = array_keys(config('mcp_servers.categories'));
|
||||
$skills = Skill::approved()->get(['slug', 'updated_at']);
|
||||
$skillCategories = array_keys(config('skills.categories'));
|
||||
$pages = Page::published()->get(['slug', 'updated_at']);
|
||||
|
||||
$xml = view('sitemap', [
|
||||
@@ -26,6 +32,10 @@ class SitemapController extends Controller
|
||||
'tags' => $tags,
|
||||
'tools' => $tools,
|
||||
'toolCategories' => $toolCategories,
|
||||
'mcpServers' => $mcpServers,
|
||||
'mcpServerCategories' => $mcpServerCategories,
|
||||
'skills' => $skills,
|
||||
'skillCategories' => $skillCategories,
|
||||
'pages' => $pages,
|
||||
])->render();
|
||||
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\Skill;
|
||||
use Illuminate\Contracts\View\View;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
class SkillController extends Controller
|
||||
{
|
||||
public function index(Request $request): View
|
||||
{
|
||||
$category = $request->string('category')->toString();
|
||||
$search = $request->string('q')->toString();
|
||||
|
||||
$skills = Skill::approved()
|
||||
->when($category, fn ($query) => $query->where('category', $category))
|
||||
->when($search, fn ($query) => $query->where('name', 'like', "%{$search}%"))
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12)
|
||||
->withQueryString();
|
||||
|
||||
return view('skills.index', [
|
||||
'skills' => $skills,
|
||||
'categories' => config('skills.categories'),
|
||||
'activeCategory' => $category,
|
||||
'search' => $search,
|
||||
'title' => 'Claude Skills Directory — '.config('app.name'),
|
||||
'description' => 'Discover Claude Skills, with docs, install instructions, and example prompts.',
|
||||
'canonical' => route('skills.index'),
|
||||
]);
|
||||
}
|
||||
|
||||
public function category(string $category): View
|
||||
{
|
||||
abort_unless(array_key_exists($category, config('skills.categories')), 404);
|
||||
|
||||
$skills = Skill::approved()
|
||||
->where('category', $category)
|
||||
->orderByDesc('featured')
|
||||
->orderByDesc('views')
|
||||
->paginate(12);
|
||||
|
||||
$label = config("skills.categories.{$category}");
|
||||
|
||||
return view('skills.category', [
|
||||
'skills' => $skills,
|
||||
'categories' => config('skills.categories'),
|
||||
'category' => $category,
|
||||
'label' => $label,
|
||||
'title' => $label.' Claude Skills — '.config('app.name'),
|
||||
'description' => "Claude Skills for {$label}.",
|
||||
'canonical' => route('skills.category', $category),
|
||||
]);
|
||||
}
|
||||
|
||||
public function show(Skill $skill): View
|
||||
{
|
||||
abort_unless($skill->approved, 404);
|
||||
|
||||
$skill->increment('views');
|
||||
|
||||
$related = Skill::approved()
|
||||
->where('category', $skill->category)
|
||||
->whereKeyNot($skill->id)
|
||||
->limit(4)
|
||||
->get();
|
||||
|
||||
return view('skills.show', [
|
||||
'skill' => $skill,
|
||||
'related' => $related,
|
||||
'title' => $skill->name.' — Claude Skills — '.config('app.name'),
|
||||
'description' => $this->metaDescription($skill->description),
|
||||
'canonical' => route('skills.show', $skill),
|
||||
'image' => $skill->logo_url,
|
||||
]);
|
||||
}
|
||||
|
||||
public function visit(Skill $skill): RedirectResponse
|
||||
{
|
||||
abort_unless($skill->approved, 404);
|
||||
|
||||
$skill->increment('clicks');
|
||||
|
||||
return redirect()->away($skill->url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate to ~155 chars without cutting a word in half. Google appends
|
||||
* its own ellipsis when it truncates in search results, so we don't add one.
|
||||
*/
|
||||
private function metaDescription(string $description, int $limit = 155): string
|
||||
{
|
||||
if (Str::length($description) <= $limit) {
|
||||
return $description;
|
||||
}
|
||||
|
||||
return Str::of($description)->limit($limit, '')->beforeLast(' ')->toString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreMcpServerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('mcp_servers', 'slug')],
|
||||
'description' => ['required', 'string'],
|
||||
'url' => ['required', 'url', 'max:255'],
|
||||
'docs_url' => ['nullable', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['required', Rule::in(array_keys(config('mcp_servers.categories')))],
|
||||
'install_command' => ['nullable', 'string'],
|
||||
'config_example' => ['nullable', 'string'],
|
||||
'transport' => ['nullable', Rule::in(array_keys(config('mcp_servers.transports')))],
|
||||
'language' => ['nullable', 'string', 'max:100'],
|
||||
'capabilities' => ['sometimes', 'array'],
|
||||
'capabilities.*' => ['string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class StoreSkillRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['required', 'string', 'max:255'],
|
||||
'slug' => ['nullable', 'string', 'max:255', Rule::unique('skills', 'slug')],
|
||||
'description' => ['required', 'string'],
|
||||
'url' => ['required', 'url', 'max:255'],
|
||||
'docs_url' => ['nullable', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['required', Rule::in(array_keys(config('skills.categories')))],
|
||||
'install_instructions' => ['nullable', 'string'],
|
||||
'example_prompt' => ['nullable', 'string'],
|
||||
'requirements' => ['nullable', 'string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateMcpServerRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('mcp_servers', 'slug')->ignore($this->route('mcp_server'))],
|
||||
'description' => ['sometimes', 'required', 'string'],
|
||||
'url' => ['sometimes', 'required', 'url', 'max:255'],
|
||||
'docs_url' => ['nullable', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['sometimes', 'required', Rule::in(array_keys(config('mcp_servers.categories')))],
|
||||
'install_command' => ['nullable', 'string'],
|
||||
'config_example' => ['nullable', 'string'],
|
||||
'transport' => ['nullable', Rule::in(array_keys(config('mcp_servers.transports')))],
|
||||
'language' => ['nullable', 'string', 'max:100'],
|
||||
'capabilities' => ['sometimes', 'array'],
|
||||
'capabilities.*' => ['string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Requests;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
class UpdateSkillRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function rules(): array
|
||||
{
|
||||
return [
|
||||
'name' => ['sometimes', 'required', 'string', 'max:255'],
|
||||
'slug' => ['sometimes', 'required', 'string', 'max:255', Rule::unique('skills', 'slug')->ignore($this->route('skill'))],
|
||||
'description' => ['sometimes', 'required', 'string'],
|
||||
'url' => ['sometimes', 'required', 'url', 'max:255'],
|
||||
'docs_url' => ['nullable', 'url', 'max:255'],
|
||||
'logo_url' => ['nullable', 'url', 'max:255'],
|
||||
'category' => ['sometimes', 'required', Rule::in(array_keys(config('skills.categories')))],
|
||||
'install_instructions' => ['nullable', 'string'],
|
||||
'example_prompt' => ['nullable', 'string'],
|
||||
'requirements' => ['nullable', 'string', 'max:255'],
|
||||
'tags' => ['sometimes', 'array'],
|
||||
'tags.*' => ['string', 'max:50'],
|
||||
'featured' => ['sometimes', 'boolean'],
|
||||
'approved' => ['sometimes', 'boolean'],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class McpServerResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'url' => $this->url,
|
||||
'docs_url' => $this->docs_url,
|
||||
'logo_url' => $this->logo_url,
|
||||
'category' => $this->category,
|
||||
'category_label' => $this->category_label,
|
||||
'install_command' => $this->install_command,
|
||||
'config_example' => $this->config_example,
|
||||
'transport' => $this->transport,
|
||||
'transport_label' => $this->transport_label,
|
||||
'language' => $this->language,
|
||||
'capabilities' => $this->capabilities,
|
||||
'tags' => $this->tags,
|
||||
'views' => $this->views,
|
||||
'clicks' => $this->clicks,
|
||||
'featured' => $this->featured,
|
||||
'approved' => $this->approved,
|
||||
'submitted_by' => new UserResource($this->whenLoaded('user')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Resources;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
class SkillResource extends JsonResource
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $this->id,
|
||||
'name' => $this->name,
|
||||
'slug' => $this->slug,
|
||||
'description' => $this->description,
|
||||
'url' => $this->url,
|
||||
'docs_url' => $this->docs_url,
|
||||
'logo_url' => $this->logo_url,
|
||||
'category' => $this->category,
|
||||
'category_label' => $this->category_label,
|
||||
'install_instructions' => $this->install_instructions,
|
||||
'example_prompt' => $this->example_prompt,
|
||||
'requirements' => $this->requirements,
|
||||
'tags' => $this->tags,
|
||||
'views' => $this->views,
|
||||
'clicks' => $this->clicks,
|
||||
'featured' => $this->featured,
|
||||
'approved' => $this->approved,
|
||||
'submitted_by' => new UserResource($this->whenLoaded('user')),
|
||||
'created_at' => $this->created_at,
|
||||
'updated_at' => $this->updated_at,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\McpServerFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'name', 'slug', 'description', 'url', 'docs_url', 'logo_url', 'category',
|
||||
'install_command', 'config_example', 'transport', 'language', 'capabilities',
|
||||
'tags', 'featured', 'approved', 'user_id',
|
||||
])]
|
||||
#[RouteKey('slug')]
|
||||
class McpServer extends Model
|
||||
{
|
||||
/** @use HasFactory<McpServerFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'capabilities' => 'array',
|
||||
'tags' => 'array',
|
||||
'featured' => 'boolean',
|
||||
'approved' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (McpServer $server) {
|
||||
if (empty($server->slug)) {
|
||||
$server->slug = Str::slug($server->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<McpServer> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function approved(Builder $query): void
|
||||
{
|
||||
$query->where('approved', true);
|
||||
}
|
||||
|
||||
protected function categoryLabel(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => config("mcp_servers.categories.{$this->category}", $this->category));
|
||||
}
|
||||
|
||||
protected function transportLabel(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => $this->transport ? config("mcp_servers.transports.{$this->transport}", $this->transport) : null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Database\Factories\SkillFactory;
|
||||
use Illuminate\Database\Eloquent\Attributes\Fillable;
|
||||
use Illuminate\Database\Eloquent\Attributes\RouteKey;
|
||||
use Illuminate\Database\Eloquent\Attributes\Scope;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
use Illuminate\Database\Eloquent\Casts\Attribute;
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
#[Fillable([
|
||||
'name', 'slug', 'description', 'url', 'docs_url', 'logo_url', 'category',
|
||||
'install_instructions', 'example_prompt', 'requirements', 'tags',
|
||||
'featured', 'approved', 'user_id',
|
||||
])]
|
||||
#[RouteKey('slug')]
|
||||
class Skill extends Model
|
||||
{
|
||||
/** @use HasFactory<SkillFactory> */
|
||||
use HasFactory;
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tags' => 'array',
|
||||
'featured' => 'boolean',
|
||||
'approved' => 'boolean',
|
||||
];
|
||||
}
|
||||
|
||||
protected static function booted(): void
|
||||
{
|
||||
static::creating(function (Skill $skill) {
|
||||
if (empty($skill->slug)) {
|
||||
$skill->slug = Str::slug($skill->name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @return BelongsTo<User, $this>
|
||||
*/
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Builder<Skill> $query
|
||||
*/
|
||||
#[Scope]
|
||||
protected function approved(Builder $query): void
|
||||
{
|
||||
$query->where('approved', true);
|
||||
}
|
||||
|
||||
protected function categoryLabel(): Attribute
|
||||
{
|
||||
return Attribute::get(fn () => config("skills.categories.{$this->category}", $this->category));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| MCP Server Categories
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Keyed by URL slug, valued by the display label. Used for the admin
|
||||
| select, public filters, category pages, and API validation.
|
||||
|
|
||||
*/
|
||||
'categories' => [
|
||||
'official' => 'Official',
|
||||
'filesystem' => 'Filesystem & Storage',
|
||||
'databases' => 'Databases',
|
||||
'developer-tools' => 'Developer Tools',
|
||||
'productivity' => 'Productivity',
|
||||
'search' => 'Search & Web',
|
||||
'browser-automation' => 'Browser Automation',
|
||||
'communication' => 'Communication',
|
||||
'data-analytics' => 'Data & Analytics',
|
||||
'other' => 'Other',
|
||||
],
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Transports
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The MCP transport(s) a server supports. Informational only -- not
|
||||
| enforced by validation since a server can support more than one.
|
||||
|
|
||||
*/
|
||||
'transports' => [
|
||||
'stdio' => 'stdio',
|
||||
'sse' => 'SSE',
|
||||
'http' => 'Streamable HTTP',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Skill Categories
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| Keyed by URL slug, valued by the display label. Used for the admin
|
||||
| select, public filters, category pages, and API validation.
|
||||
|
|
||||
*/
|
||||
'categories' => [
|
||||
'document-processing' => 'Document Processing',
|
||||
'development' => 'Development',
|
||||
'design' => 'Design & Creative',
|
||||
'productivity' => 'Productivity',
|
||||
'data-analysis' => 'Data & Analysis',
|
||||
'communication' => 'Communication',
|
||||
'other' => 'Other',
|
||||
],
|
||||
|
||||
];
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\McpServer;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<McpServer>
|
||||
*/
|
||||
class McpServerFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company().' MCP';
|
||||
$categories = array_keys(config('mcp_servers.categories'));
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'description' => fake()->paragraph(),
|
||||
'url' => fake()->url(),
|
||||
'docs_url' => fake()->url(),
|
||||
'logo_url' => 'https://picsum.photos/seed/'.Str::slug($name).'/200',
|
||||
'category' => fake()->randomElement($categories),
|
||||
'install_command' => 'npx -y '.Str::slug($name),
|
||||
'config_example' => json_encode(['mcpServers' => [Str::slug($name) => ['command' => 'npx', 'args' => ['-y', Str::slug($name)]]]], JSON_PRETTY_PRINT),
|
||||
'transport' => fake()->randomElement(['stdio', 'sse', 'http']),
|
||||
'language' => fake()->randomElement(['TypeScript', 'Python', 'Go']),
|
||||
'capabilities' => fake()->sentences(3),
|
||||
'tags' => fake()->randomElements(['open-source', 'official', 'read-only', 'requires-api-key'], rand(1, 3)),
|
||||
'views' => fake()->numberBetween(0, 5000),
|
||||
'clicks' => fake()->numberBetween(0, 500),
|
||||
'featured' => false,
|
||||
'approved' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function featured(): static
|
||||
{
|
||||
return $this->state(fn () => ['featured' => true]);
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => ['approved' => false]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
namespace Database\Factories;
|
||||
|
||||
use App\Models\Skill;
|
||||
use Illuminate\Database\Eloquent\Factories\Factory;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @extends Factory<Skill>
|
||||
*/
|
||||
class SkillFactory extends Factory
|
||||
{
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function definition(): array
|
||||
{
|
||||
$name = fake()->unique()->company().' Skill';
|
||||
$categories = array_keys(config('skills.categories'));
|
||||
|
||||
return [
|
||||
'name' => $name,
|
||||
'slug' => Str::slug($name),
|
||||
'description' => fake()->paragraph(),
|
||||
'url' => fake()->url(),
|
||||
'docs_url' => fake()->url(),
|
||||
'logo_url' => 'https://picsum.photos/seed/'.Str::slug($name).'/200',
|
||||
'category' => fake()->randomElement($categories),
|
||||
'install_instructions' => 'Copy this skill folder into ~/.claude/skills/ (or add it via a plugin marketplace) and restart Claude.',
|
||||
'example_prompt' => fake()->sentence(),
|
||||
'requirements' => fake()->randomElement(['Claude Code', 'Claude with Code Execution tool', null]),
|
||||
'tags' => fake()->randomElements(['official', 'community', 'anthropic', 'automation'], rand(1, 3)),
|
||||
'views' => fake()->numberBetween(0, 5000),
|
||||
'clicks' => fake()->numberBetween(0, 500),
|
||||
'featured' => false,
|
||||
'approved' => true,
|
||||
];
|
||||
}
|
||||
|
||||
public function featured(): static
|
||||
{
|
||||
return $this->state(fn () => ['featured' => true]);
|
||||
}
|
||||
|
||||
public function pending(): static
|
||||
{
|
||||
return $this->state(fn () => ['approved' => false]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('mcp_servers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description');
|
||||
$table->string('url');
|
||||
$table->string('docs_url')->nullable();
|
||||
$table->string('logo_url')->nullable();
|
||||
$table->string('category');
|
||||
$table->text('install_command')->nullable();
|
||||
$table->text('config_example')->nullable();
|
||||
$table->string('transport')->nullable();
|
||||
$table->string('language')->nullable();
|
||||
$table->json('capabilities')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->boolean('featured')->default(false);
|
||||
$table->boolean('approved')->default(false);
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category']);
|
||||
$table->index(['approved', 'featured']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('mcp_servers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('skills', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('name');
|
||||
$table->string('slug')->unique();
|
||||
$table->text('description');
|
||||
$table->string('url');
|
||||
$table->string('docs_url')->nullable();
|
||||
$table->string('logo_url')->nullable();
|
||||
$table->string('category');
|
||||
$table->text('install_instructions')->nullable();
|
||||
$table->text('example_prompt')->nullable();
|
||||
$table->string('requirements')->nullable();
|
||||
$table->json('tags')->nullable();
|
||||
$table->unsignedInteger('views')->default(0);
|
||||
$table->unsignedInteger('clicks')->default(0);
|
||||
$table->boolean('featured')->default(false);
|
||||
$table->boolean('approved')->default(false);
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['category']);
|
||||
$table->index(['approved', 'featured']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('skills');
|
||||
}
|
||||
};
|
||||
@@ -217,6 +217,122 @@ curl -X POST {{BASE_URL}}/api/tools \
|
||||
|
||||
Same fields as create, all optional.
|
||||
|
||||
## MCP Servers
|
||||
|
||||
A directory of Model Context Protocol servers shown at `/mcp-servers` on the public site. Fields: `id`, `name`, `slug`, `description`, `url`, `docs_url`, `logo_url`, `category`, `category_label`, `install_command`, `config_example`, `transport`, `transport_label`, `language`, `capabilities` (array of strings), `tags` (array of strings), `views`, `clicks`, `featured`, `approved`, `submitted_by`, `created_at`, `updated_at`.
|
||||
|
||||
**An MCP server is only visible on the public site once `approved` is `true`.** New servers default to `approved: false` unless you explicitly set `approved: true` in the create request — this is a moderation gate, use it deliberately.
|
||||
|
||||
### `GET /api/mcp-servers`
|
||||
|
||||
Query params (all optional): `category` (one of the keys below), `approved` (`true`/`false`).
|
||||
|
||||
Valid `category` values: `official`, `filesystem`, `databases`, `developer-tools`, `productivity`, `search`, `browser-automation`, `communication`, `data-analytics`, `other`.
|
||||
|
||||
### `POST /api/mcp-servers`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | yes | max 255 |
|
||||
| `slug` | string | no | must be unique; auto-generated from `name` if omitted |
|
||||
| `description` | string | yes | |
|
||||
| `url` | string (URL) | yes | link to the repository or homepage |
|
||||
| `docs_url` | string (URL) | no | dedicated documentation link, if different from `url` |
|
||||
| `logo_url` | string (URL) | no | |
|
||||
| `category` | string | yes | one of the category keys above |
|
||||
| `install_command` | string | no | e.g. `"npx -y @modelcontextprotocol/server-filesystem /path"` |
|
||||
| `config_example` | string | no | a config snippet for an MCP client (e.g. the `mcpServers` JSON block) |
|
||||
| `transport` | string | no | one of `stdio`, `sse`, `http` |
|
||||
| `language` | string | no | e.g. `"TypeScript"`, `"Python"` |
|
||||
| `capabilities` | array\<string\> | no | short bullet points of what it exposes, e.g. `["Read files", "Write files"]` |
|
||||
| `tags` | array\<string\> | no | e.g. `["official", "read-only"]` |
|
||||
| `featured` | boolean | no | defaults to `false` |
|
||||
| `approved` | boolean | no | defaults to `false` — see note above |
|
||||
|
||||
**Submit an MCP server listing:**
|
||||
|
||||
```bash
|
||||
curl -X POST {{BASE_URL}}/api/mcp-servers \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json" \
|
||||
-d '{
|
||||
"name": "Example Filesystem Server",
|
||||
"description": "Gives an MCP client read/write access to a local directory.",
|
||||
"url": "https://github.com/example/example-mcp-filesystem",
|
||||
"docs_url": "https://github.com/example/example-mcp-filesystem#readme",
|
||||
"category": "filesystem",
|
||||
"install_command": "npx -y @example/mcp-filesystem /path/to/dir",
|
||||
"config_example": "{\n \"mcpServers\": {\n \"filesystem\": {\n \"command\": \"npx\",\n \"args\": [\"-y\", \"@example/mcp-filesystem\", \"/path/to/dir\"]\n }\n }\n}",
|
||||
"transport": "stdio",
|
||||
"language": "TypeScript",
|
||||
"capabilities": ["Read files", "Write files", "List directories"],
|
||||
"tags": ["official", "filesystem"],
|
||||
"approved": true
|
||||
}'
|
||||
```
|
||||
|
||||
`views` and `clicks` are tracked automatically by the public site and cannot be set via the API.
|
||||
|
||||
### `GET /api/mcp-servers/{id}` · `PUT/PATCH /api/mcp-servers/{id}` · `DELETE /api/mcp-servers/{id}`
|
||||
|
||||
Same fields as create, all optional.
|
||||
|
||||
## Claude Skills
|
||||
|
||||
A directory of Claude Skills shown at `/skills` on the public site. Fields: `id`, `name`, `slug`, `description`, `url`, `docs_url`, `logo_url`, `category`, `category_label`, `install_instructions`, `example_prompt`, `requirements`, `tags` (array of strings), `views`, `clicks`, `featured`, `approved`, `submitted_by`, `created_at`, `updated_at`.
|
||||
|
||||
**A skill is only visible on the public site once `approved` is `true`.** New skills default to `approved: false` unless you explicitly set `approved: true` in the create request — this is a moderation gate, use it deliberately.
|
||||
|
||||
### `GET /api/skills`
|
||||
|
||||
Query params (all optional): `category` (one of the keys below), `approved` (`true`/`false`).
|
||||
|
||||
Valid `category` values: `document-processing`, `development`, `design`, `productivity`, `data-analysis`, `communication`, `other`.
|
||||
|
||||
### `POST /api/skills`
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|---|---|---|---|
|
||||
| `name` | string | yes | max 255 |
|
||||
| `slug` | string | no | must be unique; auto-generated from `name` if omitted |
|
||||
| `description` | string | yes | |
|
||||
| `url` | string (URL) | yes | link to the repository or homepage |
|
||||
| `docs_url` | string (URL) | no | dedicated documentation link, if different from `url` |
|
||||
| `logo_url` | string (URL) | no | |
|
||||
| `category` | string | yes | one of the category keys above |
|
||||
| `install_instructions` | string | no | e.g. `"Copy into ~/.claude/skills/ and restart Claude"` |
|
||||
| `example_prompt` | string | no | a sample prompt that would trigger/use this skill |
|
||||
| `requirements` | string | no | e.g. `"Claude Code"`, `"Claude with Code Execution tool"` |
|
||||
| `tags` | array\<string\> | no | e.g. `["official", "anthropic"]` |
|
||||
| `featured` | boolean | no | defaults to `false` |
|
||||
| `approved` | boolean | no | defaults to `false` — see note above |
|
||||
|
||||
**Submit a skill listing:**
|
||||
|
||||
```bash
|
||||
curl -X POST {{BASE_URL}}/api/skills \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" -H "Accept: application/json" \
|
||||
-d '{
|
||||
"name": "Example PDF Skill",
|
||||
"description": "Extracts text and tables from PDF files and can fill out PDF forms.",
|
||||
"url": "https://github.com/example/example-pdf-skill",
|
||||
"docs_url": "https://github.com/example/example-pdf-skill#readme",
|
||||
"category": "document-processing",
|
||||
"install_instructions": "Copy this folder into ~/.claude/skills/pdf/ and restart Claude.",
|
||||
"example_prompt": "Extract the line items from this invoice PDF into a table.",
|
||||
"requirements": "Claude Code",
|
||||
"tags": ["official", "pdf"],
|
||||
"approved": true
|
||||
}'
|
||||
```
|
||||
|
||||
`views` and `clicks` are tracked automatically by the public site and cannot be set via the API.
|
||||
|
||||
### `GET /api/skills/{id}` · `PUT/PATCH /api/skills/{id}` · `DELETE /api/skills/{id}`
|
||||
|
||||
Same fields as create, all optional.
|
||||
|
||||
## End-to-end example: publish a post as an agent
|
||||
|
||||
```bash
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
<nav class="flex shrink-0 gap-6 text-sm text-neutral-600">
|
||||
<a href="{{ route('blog.index') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">Blog</a>
|
||||
<a href="{{ route('tools.index') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">Tools</a>
|
||||
<a href="{{ route('mcp-servers.index') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">MCP Servers</a>
|
||||
<a href="{{ route('skills.index') }}" class="hover:text-neutral-900 hover:underline underline-offset-4">Skills</a>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<article class="flex flex-col rounded-lg border border-neutral-200 p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($server->logo_url)
|
||||
<img src="{{ $server->logo_url }}" alt="{{ $server->name }} logo" class="h-10 w-10 rounded-md object-cover" loading="lazy">
|
||||
@endif
|
||||
<div>
|
||||
<h3 class="font-semibold leading-tight">
|
||||
<a href="{{ route('mcp-servers.show', $server) }}" class="hover:underline">{{ $server->name }}</a>
|
||||
</h3>
|
||||
<a href="{{ route('mcp-servers.category', $server->category) }}" class="text-xs text-neutral-500 hover:text-neutral-900">
|
||||
{{ $server->category_label }}@if ($server->language) · {{ $server->language }} @endif
|
||||
</a>
|
||||
</div>
|
||||
@if ($server->featured)
|
||||
<span class="ml-auto rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">Featured</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-3 flex-1 text-sm text-neutral-600">{{ str($server->description)->limit(120) }}</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
@if ($server->transport_label)
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700">{{ $server->transport_label }}</span>
|
||||
@endif
|
||||
<a
|
||||
href="{{ route('mcp-servers.show', $server) }}"
|
||||
class="ml-auto text-sm font-medium text-neutral-900 hover:underline"
|
||||
>
|
||||
View details →
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,32 @@
|
||||
<article class="flex flex-col rounded-lg border border-neutral-200 p-5">
|
||||
<div class="flex items-center gap-3">
|
||||
@if ($skill->logo_url)
|
||||
<img src="{{ $skill->logo_url }}" alt="{{ $skill->name }} logo" class="h-10 w-10 rounded-md object-cover" loading="lazy">
|
||||
@endif
|
||||
<div>
|
||||
<h3 class="font-semibold leading-tight">
|
||||
<a href="{{ route('skills.show', $skill) }}" class="hover:underline">{{ $skill->name }}</a>
|
||||
</h3>
|
||||
<a href="{{ route('skills.category', $skill->category) }}" class="text-xs text-neutral-500 hover:text-neutral-900">
|
||||
{{ $skill->category_label }}
|
||||
</a>
|
||||
</div>
|
||||
@if ($skill->featured)
|
||||
<span class="ml-auto rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800">Featured</span>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-3 flex-1 text-sm text-neutral-600">{{ str($skill->description)->limit(120) }}</p>
|
||||
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-2">
|
||||
@if ($skill->requirements)
|
||||
<span class="rounded-full bg-neutral-100 px-2 py-0.5 text-xs text-neutral-700">{{ $skill->requirements }}</span>
|
||||
@endif
|
||||
<a
|
||||
href="{{ route('skills.show', $skill) }}"
|
||||
class="ml-auto text-sm font-medium text-neutral-900 hover:underline"
|
||||
>
|
||||
View details →
|
||||
</a>
|
||||
</div>
|
||||
</article>
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => [
|
||||
['@type' => 'ListItem', 'position' => 1, 'name' => 'MCP Servers', 'item' => route('mcp-servers.index')],
|
||||
['@type' => 'ListItem', 'position' => 2, 'name' => $label, 'item' => $canonical],
|
||||
],
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<p class="text-sm text-neutral-500">
|
||||
<a href="{{ route('mcp-servers.index') }}" class="hover:underline">MCP Servers</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
{{ $label }}
|
||||
</p>
|
||||
<h1 class="mt-2 text-3xl font-bold tracking-tight">{{ $label }} MCP Servers</h1>
|
||||
|
||||
<nav class="mt-4 flex flex-wrap gap-2">
|
||||
<a href="{{ route('mcp-servers.index') }}" class="rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700 hover:bg-neutral-200">
|
||||
All
|
||||
</a>
|
||||
@foreach ($categories as $slug => $categoryLabel)
|
||||
<a
|
||||
href="{{ route('mcp-servers.category', $slug) }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $category === $slug ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
{{ $categoryLabel }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@forelse ($servers as $server)
|
||||
<x-mcp-server-card :server="$server" />
|
||||
@empty
|
||||
<p class="text-neutral-500 sm:col-span-2">No MCP servers in this category yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
{{ $servers->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => 'MCP Servers Directory',
|
||||
'url' => $canonical,
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight">MCP Servers Directory</h1>
|
||||
<p class="mt-2 text-neutral-600">A curated list of Model Context Protocol servers, with docs, install instructions, and config examples.</p>
|
||||
|
||||
<form action="{{ route('mcp-servers.index') }}" method="GET" class="mt-6">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search MCP servers…"
|
||||
class="w-full rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
</form>
|
||||
|
||||
<nav class="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="{{ route('mcp-servers.index') }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $activeCategory === '' ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
All
|
||||
</a>
|
||||
@foreach ($categories as $slug => $label)
|
||||
<a
|
||||
href="{{ route('mcp-servers.category', $slug) }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $activeCategory === $slug ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@forelse ($servers as $server)
|
||||
<x-mcp-server-card :server="$server" />
|
||||
@empty
|
||||
<p class="text-neutral-500 sm:col-span-2">No MCP servers found.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
{{ $servers->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,103 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical" :image="$image ?? null">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'SoftwareApplication',
|
||||
'name' => $server->name,
|
||||
'description' => $description,
|
||||
'url' => $server->url,
|
||||
...($server->logo_url ? ['image' => $server->logo_url] : []),
|
||||
'applicationCategory' => $server->category_label,
|
||||
...($server->language ? ['programmingLanguage' => $server->language] : []),
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<p class="text-sm text-neutral-500">
|
||||
<a href="{{ route('mcp-servers.index') }}" class="hover:underline">MCP Servers</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<a href="{{ route('mcp-servers.category', $server->category) }}" class="hover:underline">{{ $server->category_label }}</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex items-start gap-4">
|
||||
@if ($server->logo_url)
|
||||
<img src="{{ $server->logo_url }}" alt="{{ $server->name }} logo" class="h-16 w-16 rounded-lg object-cover">
|
||||
@endif
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ $server->name }}</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
{{ $server->category_label }}
|
||||
@if ($server->language) · {{ $server->language }} @endif
|
||||
@if ($server->transport_label) · {{ $server->transport_label }} @endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="{{ route('mcp-servers.visit', $server) }}"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
class="inline-block rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700"
|
||||
>
|
||||
View repository →
|
||||
</a>
|
||||
@if ($server->docs_url)
|
||||
<a
|
||||
href="{{ $server->docs_url }}"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
class="inline-block rounded-lg border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-900 hover:bg-neutral-50"
|
||||
>
|
||||
Documentation →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-neutral-700">{{ $server->description }}</p>
|
||||
|
||||
@if ($server->install_command)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold">Install</h2>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-neutral-900 p-4 text-sm text-neutral-100"><code>{{ $server->install_command }}</code></pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($server->config_example)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold">Example configuration</h2>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-neutral-900 p-4 text-sm text-neutral-100"><code>{{ $server->config_example }}</code></pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($server->capabilities))
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold">Capabilities</h2>
|
||||
<ul class="mt-3 list-inside list-disc space-y-1 text-neutral-700">
|
||||
@foreach ($server->capabilities as $capability)
|
||||
<li>{{ $capability }}</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($server->tags))
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
@foreach ($server->tags as $tag)
|
||||
<span class="rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700">#{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($related->isNotEmpty())
|
||||
<div class="mt-12 border-t border-neutral-200 pt-8">
|
||||
<h2 class="text-lg font-semibold">More {{ $server->category_label }} servers</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach ($related as $relatedServer)
|
||||
<x-mcp-server-card :server="$relatedServer" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
@@ -10,6 +10,16 @@
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ route('mcp-servers.index') }}</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
<url>
|
||||
<loc>{{ route('skills.index') }}</loc>
|
||||
<changefreq>daily</changefreq>
|
||||
<priority>1.0</priority>
|
||||
</url>
|
||||
@foreach ($categories as $category)
|
||||
<url>
|
||||
<loc>{{ route('blog.category', $category) }}</loc>
|
||||
@@ -49,6 +59,36 @@
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach ($mcpServerCategories as $mcpServerCategory)
|
||||
<url>
|
||||
<loc>{{ route('mcp-servers.category', $mcpServerCategory) }}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach ($mcpServers as $server)
|
||||
<url>
|
||||
<loc>{{ route('mcp-servers.show', $server) }}</loc>
|
||||
<lastmod>{{ $server->updated_at->toAtomString() }}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach ($skillCategories as $skillCategory)
|
||||
<url>
|
||||
<loc>{{ route('skills.category', $skillCategory) }}</loc>
|
||||
<changefreq>weekly</changefreq>
|
||||
<priority>0.6</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach ($skills as $skill)
|
||||
<url>
|
||||
<loc>{{ route('skills.show', $skill) }}</loc>
|
||||
<lastmod>{{ $skill->updated_at->toAtomString() }}</lastmod>
|
||||
<changefreq>monthly</changefreq>
|
||||
<priority>0.7</priority>
|
||||
</url>
|
||||
@endforeach
|
||||
@foreach ($pages as $page)
|
||||
<url>
|
||||
<loc>{{ route('pages.show', $page) }}</loc>
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'BreadcrumbList',
|
||||
'itemListElement' => [
|
||||
['@type' => 'ListItem', 'position' => 1, 'name' => 'Claude Skills', 'item' => route('skills.index')],
|
||||
['@type' => 'ListItem', 'position' => 2, 'name' => $label, 'item' => $canonical],
|
||||
],
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<p class="text-sm text-neutral-500">
|
||||
<a href="{{ route('skills.index') }}" class="hover:underline">Claude Skills</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
{{ $label }}
|
||||
</p>
|
||||
<h1 class="mt-2 text-3xl font-bold tracking-tight">{{ $label }} Claude Skills</h1>
|
||||
|
||||
<nav class="mt-4 flex flex-wrap gap-2">
|
||||
<a href="{{ route('skills.index') }}" class="rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700 hover:bg-neutral-200">
|
||||
All
|
||||
</a>
|
||||
@foreach ($categories as $slug => $categoryLabel)
|
||||
<a
|
||||
href="{{ route('skills.category', $slug) }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $category === $slug ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
{{ $categoryLabel }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@forelse ($skills as $skill)
|
||||
<x-skill-card :skill="$skill" />
|
||||
@empty
|
||||
<p class="text-neutral-500 sm:col-span-2">No skills in this category yet.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
{{ $skills->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,54 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'CollectionPage',
|
||||
'name' => 'Claude Skills Directory',
|
||||
'url' => $canonical,
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<h1 class="text-3xl font-bold tracking-tight">Claude Skills Directory</h1>
|
||||
<p class="mt-2 text-neutral-600">A curated list of Claude Skills, with docs, install instructions, and example prompts.</p>
|
||||
|
||||
<form action="{{ route('skills.index') }}" method="GET" class="mt-6">
|
||||
<input
|
||||
type="search"
|
||||
name="q"
|
||||
value="{{ $search }}"
|
||||
placeholder="Search skills…"
|
||||
class="w-full rounded-lg border border-neutral-300 px-4 py-2 text-sm focus:border-neutral-900 focus:outline-none"
|
||||
>
|
||||
</form>
|
||||
|
||||
<nav class="mt-4 flex flex-wrap gap-2">
|
||||
<a
|
||||
href="{{ route('skills.index') }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $activeCategory === '' ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
All
|
||||
</a>
|
||||
@foreach ($categories as $slug => $label)
|
||||
<a
|
||||
href="{{ route('skills.category', $slug) }}"
|
||||
class="rounded-full px-3 py-1 text-sm {{ $activeCategory === $slug ? 'bg-neutral-900 text-white' : 'bg-neutral-100 text-neutral-700 hover:bg-neutral-200' }}"
|
||||
>
|
||||
{{ $label }}
|
||||
</a>
|
||||
@endforeach
|
||||
</nav>
|
||||
|
||||
<div class="mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@forelse ($skills as $skill)
|
||||
<x-skill-card :skill="$skill" />
|
||||
@empty
|
||||
<p class="text-neutral-500 sm:col-span-2">No skills found.</p>
|
||||
@endforelse
|
||||
</div>
|
||||
|
||||
<div class="mt-10">
|
||||
{{ $skills->links() }}
|
||||
</div>
|
||||
</x-layouts.app>
|
||||
@@ -0,0 +1,90 @@
|
||||
<x-layouts.app :title="$title" :description="$description" :canonical="$canonical" :image="$image ?? null">
|
||||
@push('jsonld')
|
||||
<script type="application/ld+json">
|
||||
{!! json_encode([
|
||||
'@@context' => 'https://schema.org',
|
||||
'@type' => 'SoftwareApplication',
|
||||
'name' => $skill->name,
|
||||
'description' => $description,
|
||||
'url' => $skill->url,
|
||||
...($skill->logo_url ? ['image' => $skill->logo_url] : []),
|
||||
'applicationCategory' => $skill->category_label,
|
||||
]) !!}
|
||||
</script>
|
||||
@endpush
|
||||
|
||||
<p class="text-sm text-neutral-500">
|
||||
<a href="{{ route('skills.index') }}" class="hover:underline">Claude Skills</a>
|
||||
<span aria-hidden="true">/</span>
|
||||
<a href="{{ route('skills.category', $skill->category) }}" class="hover:underline">{{ $skill->category_label }}</a>
|
||||
</p>
|
||||
|
||||
<div class="mt-4 flex items-start gap-4">
|
||||
@if ($skill->logo_url)
|
||||
<img src="{{ $skill->logo_url }}" alt="{{ $skill->name }} logo" class="h-16 w-16 rounded-lg object-cover">
|
||||
@endif
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold tracking-tight">{{ $skill->name }}</h1>
|
||||
<p class="mt-1 text-sm text-neutral-500">
|
||||
{{ $skill->category_label }}
|
||||
@if ($skill->requirements) · {{ $skill->requirements }} @endif
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex flex-wrap gap-3">
|
||||
<a
|
||||
href="{{ route('skills.visit', $skill) }}"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
class="inline-block rounded-lg bg-neutral-900 px-5 py-2.5 text-sm font-medium text-white hover:bg-neutral-700"
|
||||
>
|
||||
View repository →
|
||||
</a>
|
||||
@if ($skill->docs_url)
|
||||
<a
|
||||
href="{{ $skill->docs_url }}"
|
||||
target="_blank"
|
||||
rel="nofollow noopener"
|
||||
class="inline-block rounded-lg border border-neutral-300 px-5 py-2.5 text-sm font-medium text-neutral-900 hover:bg-neutral-50"
|
||||
>
|
||||
Documentation →
|
||||
</a>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
<p class="mt-8 text-neutral-700">{{ $skill->description }}</p>
|
||||
|
||||
@if ($skill->install_instructions)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold">How to install</h2>
|
||||
<p class="mt-3 text-neutral-700">{{ $skill->install_instructions }}</p>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($skill->example_prompt)
|
||||
<div class="mt-8">
|
||||
<h2 class="text-lg font-semibold">Example prompt</h2>
|
||||
<pre class="mt-3 overflow-x-auto rounded-lg bg-neutral-900 p-4 text-sm text-neutral-100"><code>{{ $skill->example_prompt }}</code></pre>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if (! empty($skill->tags))
|
||||
<div class="mt-8 flex flex-wrap gap-2">
|
||||
@foreach ($skill->tags as $tag)
|
||||
<span class="rounded-full bg-neutral-100 px-3 py-1 text-sm text-neutral-700">#{{ $tag }}</span>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@if ($related->isNotEmpty())
|
||||
<div class="mt-12 border-t border-neutral-200 pt-8">
|
||||
<h2 class="text-lg font-semibold">More {{ $skill->category_label }} skills</h2>
|
||||
<div class="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
@foreach ($related as $relatedSkill)
|
||||
<x-skill-card :skill="$relatedSkill" />
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</x-layouts.app>
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
use App\Http\Controllers\Api\AuthController;
|
||||
use App\Http\Controllers\Api\CategoryController;
|
||||
use App\Http\Controllers\Api\McpServerController;
|
||||
use App\Http\Controllers\Api\PostController;
|
||||
use App\Http\Controllers\Api\SkillController;
|
||||
use App\Http\Controllers\Api\TagController;
|
||||
use App\Http\Controllers\Api\ToolController;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -18,4 +20,6 @@ Route::middleware('auth:sanctum')->group(function () {
|
||||
Route::apiResource('categories', CategoryController::class)->scoped(['category' => 'id'])->names('api.categories');
|
||||
Route::apiResource('tags', TagController::class)->scoped(['tag' => 'id'])->names('api.tags');
|
||||
Route::apiResource('tools', ToolController::class)->scoped(['tool' => 'id'])->names('api.tools');
|
||||
Route::apiResource('mcp-servers', McpServerController::class)->scoped(['mcp_server' => 'id'])->names('api.mcp_servers');
|
||||
Route::apiResource('skills', SkillController::class)->scoped(['skill' => 'id'])->names('api.skills');
|
||||
});
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
|
||||
use App\Http\Controllers\ApiDocsController;
|
||||
use App\Http\Controllers\BlogController;
|
||||
use App\Http\Controllers\McpServerController;
|
||||
use App\Http\Controllers\PageController;
|
||||
use App\Http\Controllers\SitemapController;
|
||||
use App\Http\Controllers\SkillController;
|
||||
use App\Http\Controllers\ToolController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
@@ -23,6 +25,16 @@ Route::get('/tools/category/{category}', [ToolController::class, 'category'])->n
|
||||
Route::get('/tools/{tool:slug}/visit', [ToolController::class, 'visit'])->name('tools.visit');
|
||||
Route::get('/tools/{tool:slug}', [ToolController::class, 'show'])->name('tools.show');
|
||||
|
||||
Route::get('/mcp-servers', [McpServerController::class, 'index'])->name('mcp-servers.index');
|
||||
Route::get('/mcp-servers/category/{category}', [McpServerController::class, 'category'])->name('mcp-servers.category');
|
||||
Route::get('/mcp-servers/{mcpServer:slug}/visit', [McpServerController::class, 'visit'])->name('mcp-servers.visit');
|
||||
Route::get('/mcp-servers/{mcpServer:slug}', [McpServerController::class, 'show'])->name('mcp-servers.show');
|
||||
|
||||
Route::get('/skills', [SkillController::class, 'index'])->name('skills.index');
|
||||
Route::get('/skills/category/{category}', [SkillController::class, 'category'])->name('skills.category');
|
||||
Route::get('/skills/{skill:slug}/visit', [SkillController::class, 'visit'])->name('skills.visit');
|
||||
Route::get('/skills/{skill:slug}', [SkillController::class, 'show'])->name('skills.show');
|
||||
|
||||
// Kept last: a single-segment catch-all for static pages (e.g. /about).
|
||||
// Must not precede any other single-segment route above (e.g. /tools, /login).
|
||||
Route::get('/{page:slug}', [PageController::class, 'show'])->name('pages.show');
|
||||
|
||||
Reference in New Issue
Block a user