{
"$type": "site.standard.document",
"bskyPostRef": {
"cid": "bafyreidvkgby5rc2zzisvzwwj46lzksud6t2rwvbf4v4m3ojofikarpoly",
"uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mohzccdbopf2"
},
"coverImage": {
"$type": "blob",
"ref": {
"$link": "bafkreiegx2hnd5tt5u6rp3lkqtv2oyfgcv3re7qd2lnmrpwhc5uffyzlry"
},
"mimeType": "image/webp",
"size": 111894
},
"path": "/nasrulhazim/one-step-install-and-one-flag-oauth-for-a-laravel-mcp-server-49op",
"publishedAt": "2026-06-17T09:13:06.000Z",
"site": "https://dev.to",
"tags": [
"laravel",
"php",
"opensource",
"architecture",
"https://github.com/cleaniquecoders/laravel-mcp-kit"
],
"textContent": "I tagged `cleaniquecoders/laravel-mcp-kit` 1.0.0 today, and most of the work that got it there was about one thing: making the _setup_ boring. A kit is only worth shipping if dropping it into the tenth host app feels the same as the first. So the day went into collapsing a fiddly multi-command setup into `php artisan mcp-kit:install`, and turning \"enable OAuth\" from a service-provider editing session into a single env flag. Here's how it's wired, and the design calls behind it.\n\nRepo if you want to read along: https://github.com/cleaniquecoders/laravel-mcp-kit\n\n## The problem: setup that drifts\n\nThe kit ships a ready-to-use \"tasks\" MCP server with two transports — a local STDIO one for `php artisan mcp:start`, and an HTTP one for remote connectors. The HTTP transport can authenticate two ways: token-only via Sanctum (the default), or the full OAuth 2.1 authorization-code + PKCE flow for header-less connectors like claude.ai.\n\nBefore today, standing that up meant a sequence every host had to get right by hand: publish config, publish migrations, publish a consent view, install Passport, generate keys, register the `api` guard, point Passport at the consent view, load Passport's migrations. Miss one and you get a runtime error three steps later. That's exactly the kind of tribal setup that drifts between projects — and the kit's whole pitch is \"same every time.\"\n\n## One command instead of a checklist\n\nThe fix is an invokable-style install command that wraps the whole dance. The signature carries the optional pieces as flags, so a token-only install and a full OAuth install are the same command with different switches:\n\n\n\n protected $signature = 'mcp-kit:install\n {--oauth : Also stand up the OAuth 2.1 (Passport) transport}\n {--ui : Also publish the Livewire + Flux token-management UI}\n {--force : Overwrite any files that were already published}';\n\n public function handle(): int\n {\n $this->components->info('Installing the Laravel MCP Kit');\n\n $this->publish('mcp-kit-config', 'config');\n $this->publish('mcp-kit-migrations', 'migration');\n\n if ($this->option('oauth') && ! $this->installOAuth()) {\n return self::FAILURE;\n }\n\n if ($this->option('ui')) {\n $this->installUi();\n }\n\n $this->nextSteps();\n\n return self::SUCCESS;\n }\n\n\nTwo design choices worth calling out.\n\nFirst, **everything is idempotent** , and `--force` is the only way to overwrite. Re-running the command on a half-set-up app is safe — it republishes what's missing and leaves the rest. That matters because install commands get re-run constantly during development, and a command that's destructive on the second run is a footgun.\n\nSecond, the command **fails loudly when a precondition is missing** instead of limping forward. The `--oauth` branch checks Passport is actually installed before touching anything, and bails with instructions rather than producing a half-wired OAuth setup:\n\n\n\n protected function installOAuth(): bool\n {\n if (! class_exists(Passport::class)) {\n $this->components->warn('Laravel Passport is not installed — the OAuth transport needs it.');\n $this->components->bulletList([\n 'Install it: composer require laravel/passport',\n 'Then re-run: php artisan mcp-kit:install --oauth',\n ]);\n\n return false;\n }\n\n $this->publish('mcp-kit-views', 'consent view');\n\n // passport:keys is only available once Passport's provider is registered.\n if ($this->getApplication()->has('passport:keys')) {\n $this->components->task('Generating Passport encryption keys', function () {\n $this->callSilently('passport:keys', array_filter([\n '--force' => $this->option('force') ?: null,\n ]));\n\n return true;\n });\n } else {\n $this->components->warn('Skipped passport:keys — run it once Passport is fully registered.');\n }\n\n return true;\n }\n\n\nNotice the command only does the _one-time, imperative_ steps — publish files, generate keys. It deliberately does **not** edit the host's service providers or config to make OAuth work at runtime. That part belongs somewhere idempotent-by-nature: the package's own service provider.\n\n## One-flag OAuth: let the provider do the wiring\n\nThis is the half I'm happiest with. The goal was: set `MCP_KIT_WEB_OAUTH_ENABLED=true`, run `migrate`, generate keys — and OAuth just works. No host service-provider edits at all.\n\nThe trick is that the package's service provider does the runtime wiring itself, guarded so it never stomps on a real app's config. Passport 13 stopped auto-loading its `oauth_*` migrations and ships no consent view, so the provider fills both gaps — but both are opt-out via config, so a host that wants its own branded consent screen or its own migration strategy just overrides them:\n\n\n\n protected function configureOAuth(): void\n {\n // ... only runs when OAuth is enabled AND Passport is installed ...\n\n // Consent screen for the auth-code flow. Passport 13 ships none, so wire\n // our publishable stub unless the host opted out or pointed at their own.\n $view = config('mcp-kit.web.oauth.authorization_view', 'mcp-kit::authorize');\n\n if ($view !== false && $view !== null) {\n Passport::authorizationView($view);\n }\n\n // Passport 13 no longer auto-loads its oauth_* migrations. Register them\n // so a plain `migrate` creates the tables — saving the host a\n // `vendor:publish --tag=passport-migrations` step.\n if (config('mcp-kit.web.oauth.load_migrations', true)) {\n $passportMigrations = dirname((new ReflectionClass(Passport::class))->getFileName(), 2)\n .'/database/migrations';\n\n if (is_dir($passportMigrations)) {\n $this->loadMigrationsFrom($passportMigrations);\n }\n }\n }\n\n\nThe principle here is the one I keep coming back to with packages: **the host app's config always wins.** The `api` guard is only registered if the host hasn't defined one. The consent view binding is skipped if config says `false`. Migration loading is opt-out. A package that quietly overrides your `config/auth.php` is a package you'll rip out the first time it surprises you — so every piece of auto-wiring here has an escape hatch, and the escape hatch is plain config, not a subclass.\n\nLocating Passport's migrations by reflecting on the class file rather than hardcoding `vendor/laravel/passport/...` is a small thing, but it survives Composer installing the dependency somewhere unexpected (a merged vendor dir, a path repo during local dev). Resolve from the class, not from an assumed path.\n\n## Prove the whole flow end to end\n\nA setup this convenient is only trustworthy if there's a test that drives the _actual_ OAuth flow — not just \"the config key exists.\" So the headline test walks the entire authorization-code + PKCE path the way claude.ai would: dynamic client registration, the consent screen, approval, code-for-token exchange, then a real authenticated MCP call with the issued token.\n\n\n\n it('issues a token through the consent flow that authenticates the MCP endpoint', function () {\n $user = User::create(['email' => 'imran@example.test', 'grants' => ['view', 'manage']]);\n\n // 1. The connector self-registers (DCR) — a public client, so PKCE, no secret.\n $clientId = $this->postJson('/oauth/register', [\n 'client_name' => 'Claude',\n 'redirect_uris' => ['https://claude.ai/api/mcp/auth_callback'],\n ])->assertCreated()->json('client_id');\n\n // 2. PKCE pair.\n $verifier = str_repeat('mcp-kit-pkce-verifier-0123456789', 2); // 64 chars\n $challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');\n\n // 3. The signed-in user sees the kit's consent screen (auto-wired, no host edit).\n $this->actingAs($user)\n ->get('/oauth/authorize?'.http_build_query([\n 'client_id' => $clientId,\n 'redirect_uri' => 'https://claude.ai/api/mcp/auth_callback',\n 'response_type' => 'code',\n 'scope' => 'mcp:use',\n 'state' => 'opaque-state',\n 'code_challenge' => $challenge,\n 'code_challenge_method' => 'S256',\n ]))\n ->assertOk()\n ->assertSee('Authorization Request');\n\n // 4. Approve → redirect back with the authorization code.\n $location = $this->post('/oauth/authorize', ['auth_token' => session('authToken')])\n ->assertRedirect()->headers->get('Location');\n\n parse_str(parse_url($location, PHP_URL_QUERY), $callback);\n\n // 5. Exchange the code for a token (PKCE verifier, no secret).\n $accessToken = $this->post('/oauth/token', [\n 'grant_type' => 'authorization_code',\n 'client_id' => $clientId,\n 'redirect_uri' => 'https://claude.ai/api/mcp/auth_callback',\n 'code_verifier' => $verifier,\n 'code' => $callback['code'],\n ])->assertOk()->json('access_token');\n\n // 6. The Passport token authenticates the MCP endpoint.\n $this->withHeader('Authorization', \"Bearer {$accessToken}\")\n ->postJson(route('mcp-kit.tasks'), ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'ping'])\n ->assertOk();\n });\n\n\nThere's a companion test for the cancel path — the user declines, and Passport redirects back with `access_denied` and **no** code. The denial path is where auth bugs hide; a flow that only tests the happy path will happily hand out tokens to someone who clicked \"No.\"\n\nTwo testbench details that saved me time. The browser flow posts without a CSRF token, so the test disables `VerifyCsrfToken` — we're exercising OAuth issuance, not Laravel's CSRF guard. And the test case forces the array cache driver so token issuance doesn't need a cache table to exist in the test DB. Small frictions, but they're the difference between \"the suite runs anywhere\" and \"works on my machine.\"\n\n## What this unlocks\n\nThe payoff showed up immediately in another project: the provisioning tool that scaffolds new Laravel apps now just adds the kit to its package list and runs `php artisan mcp-kit:install --no-interaction` as one more bootstrap step. Every freshly provisioned app gets a working MCP server with zero bespoke setup. That's the whole point of collapsing setup into one command — it stops being a thing a human has to remember and becomes a line in a script.\n\nThe one piece the host still owns is authorization: the kit ships no permission system, it just _requires_ two gates (`mcp-kit.view-tasks`, `mcp-kit.manage-tasks`) to be defined. That's deliberate — the kit shouldn't guess how your app does permissions. Map those two abilities to whatever your app already uses (a policy, a gate, a permission package) and you're done.\n\nNext up: documenting the OAuth path properly in the new `docs/` tree, and probably a `--ui` walkthrough for the token-management screens. But the foundation I wanted for 1.0.0 is there: install in one command, enable OAuth with one flag, and a test that proves the whole handshake actually works.",
"title": "One-Step Install and One-Flag OAuth for a Laravel MCP Server"
}