{
  "$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"
}