mcps and skills

This commit is contained in:
2026-08-11 00:42:46 +03:00
parent 6ffb24f20a
commit 4e67077e12
44 changed files with 2158 additions and 0 deletions
@@ -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();
+103
View File
@@ -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'],
];
}
}
+37
View File
@@ -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'],
];
}
}
+37
View File
@@ -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'],
];
}
}
+41
View File
@@ -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,
];
}
}
+38
View File
@@ -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,
];
}
}
+72
View File
@@ -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);
}
}
+66
View File
@@ -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));
}
}
+42
View File
@@ -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',
],
];
+24
View File
@@ -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',
],
];
+52
View File
@@ -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]);
}
}
+50
View File
@@ -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');
}
};
+116
View File
@@ -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) &middot; {{ $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 &rarr;
</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 &rarr;
</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&hellip;"
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>
+103
View File
@@ -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) &middot; {{ $server->language }} @endif
@if ($server->transport_label) &middot; {{ $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 &rarr;
</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 &rarr;
</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>
+40
View File
@@ -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>
+47
View File
@@ -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>
+54
View File
@@ -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&hellip;"
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>
+90
View File
@@ -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) &middot; {{ $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 &rarr;
</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 &rarr;
</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>
+4
View File
@@ -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');
});
+12
View File
@@ -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');