feat(mcp): add OAuth 2.1 for Claude Desktop & ChatGPT connectors (Phase 3) (#691)

## MCP Phase 3 — OAuth 2.1 for Claude Desktop/web & ChatGPT connectors

Phase 1 shipped a read-only MCP server (#689); Phase 2 added write tools
+ the read/read_write token scope (#690). This phase adds **OAuth 2.1
(Authorization Code + PKCE)** so Anthropic's Claude Desktop/web custom
connectors and OpenAI's ChatGPT connectors can authenticate — those
clients sign in with OAuth rather than pasting a static bearer token, so
until now they only saw a "coming soon" note.

It reuses `laravel/mcp`'s built-in OAuth support (inert until Passport
is installed) wired to `laravel/passport ^13`. We do not hand-write the
authorization server, discovery endpoints, DCR endpoint, or the
`WWW-Authenticate` challenge — the package provides all of it.

### What's in it
- **`laravel/passport ^13`** + an `api` (passport) guard alongside the
existing session `web` guard; Passport migrations (UUID user columns),
config, and signing keys.
- **`Mcp::oauthRoutes()`** — RFC 8414/9728 discovery, RFC 7591 DCR
(`oauth/register`), and the `mcp:use` scope.
- **A second MCP endpoint `POST /mcp/oauth`** guarded by `auth:api`. The
existing Sanctum `/mcp` endpoint (Claude Code static PAT) is left 100%
unchanged.
- **On-brand OAuth consent screen** (Blade, light + dark, localized)
naming the connecting client and its redirect host, and stating plainly
what the connection can do (read/analyse + make changes, bank-connected
data excepted).
- **Settings UI**: the "Claude Desktop & ChatGPT" block now shows real
connect instructions (the `/mcp/oauth` URL to add as a custom connector,
no token needed) instead of "coming soon".

## Decision #1 — OAuth connections have read + write access

`laravel/mcp` advertises and uses a single `mcp:use` scope; it has no
read/write granularity, so there is no per-connection scope choice over
OAuth. **OAuth connections get full read + write access**, gated by the
user explicitly approving the connection on the Whisper Money consent
screen. (An earlier revision made them read-only; that restriction has
been lifted per request.)

`WriteTool` (`app/Mcp/Tools/WriteTool.php`) grants writes when the
request resolves through the `api` (Passport) guard **or** carries a
Sanctum `mcp:write` ability; a read-only Sanctum PAT is still rejected.
Bank-connected accounts and their transactions remain read-only for
every caller (only manual data can be created/edited/deleted; any
transaction can still be categorised/labelled). The consent screen and
settings copy state the read + write capability and the bank-connected
exception.

Possible follow-up: a consent-time read-only/read-write toggle, if
per-connection granularity is wanted (not offered by the standard MCP
OAuth flow's single scope).

## Other locked decisions
- **Route topology**: a separate `/mcp/oauth` endpoint rather than
multi-guarding `/mcp`. Keeps the Claude Code path unchanged (its
`abilities:mcp:read` gate would 403 an OAuth `mcp:use` token) and gives
each client type a clean documented URL. The package's nested discovery
`/.well-known/oauth-protected-resource/mcp/oauth` returns `resource =
url('/mcp/oauth')`.
- **Registration**: ship DCR (`oauth/register`). Redirect allowlist
tightened to `https://claude.ai` and `https://chatgpt.com` only — no
wildcard. CIMD is a possible later enhancement; both clients accept DCR.

## Deviation from the original plan — the User model is untouched
The plan proposed aliasing Passport's `HasApiTokens` trait alongside
Sanctum's (with `insteadof`/`as`) and implementing `OAuthenticatable`.
**Both are impossible here and, it turns out, unnecessary:**
- The two `HasApiTokens` traits declare an **incompatible `$accessToken`
property** (Sanctum untyped vs Passport `?ScopeAuthorizable`), which is
a hard PHP fatal that `insteadof` cannot resolve (it only resolves
methods).
- `OAuthenticatable::tokens(): HasMany` is incompatible with Sanctum's
canonical `tokens(): MorphMany`, and the Claude Code PAT suite depends
on Sanctum's `tokens()`. The interface is never enforced at runtime by
Passport (docblock-only).
- Passport's resource guard only calls `$user->withAccessToken()`, which
Sanctum already provides (untyped, so it accepts the Passport
`AccessToken`); and Passport's `AccessToken::can()` makes
`tokenCan('mcp:write')` behave correctly for OAuth tokens. So Sanctum
stays canonical and the Claude Code PAT path is genuinely unchanged.

## Signing keys (deploy note)
Passport signs OAuth tokens with a key pair. This PR provisions it
everywhere it's needed: CI (`passport:keys` before tests), the
production Docker entrypoint (generates into the persisted `storage/`
volume unless provided via `PASSPORT_PRIVATE_KEY`/`PASSPORT_PUBLIC_KEY`
env), `worktree.sh`, and a documented `.env.example` entry. **For a
multi-instance deployment, set `PASSPORT_*` env** so every instance
validates tokens with the same key.

## Tests (`tests/Feature/Mcp/McpOAuthTest.php`)
Discovery metadata (RFC 9728/8414), the mandatory **401 bootstrap**
challenge + `WWW-Authenticate` header, DCR (allowed + rejected redirect
URIs), the full **Authorization Code + PKCE** flow reaching a read tool,
and **write access over OAuth** (an OAuth connection calling
`create_label` succeeds and the row is created). The existing
`McpTokenTest` / `Mcp/*` suites (incl. the read-only Sanctum PAT
guardrail) and `LocalizationTest` still pass unchanged.

## QA
- **Protocol** (curl, over HTTPS): both discovery endpoints return the
exact required JSON; unauthenticated `POST /mcp/oauth` returns `401` +
`WWW-Authenticate: Bearer …
resource_metadata="…/.well-known/oauth-protected-resource/mcp/oauth"`;
DCR accepts `claude.ai`/`chatgpt.com` callbacks and rejects others with
`400 invalid_redirect_uri`.
- **Browser**: consent screen verified in light and dark mode (client
name, signed-in email, redirect host, read + write capability +
bank-connected read-only note, Cancel/Connect); updated settings page
verified. No JS errors.
- Full PKCE token exchange + a write tool call is covered by the green
Pest e2e test.

## Fast-follows (not in this PR)
- **"Connected apps" revoke UI** — `McpTokenController` manages only
Sanctum PATs today, so there's no in-app revoke for OAuth grants yet.
The consent copy says "disconnect from the connected app" for now; a
Passport-grant list + revoke is the top follow-up (more important now
that OAuth grants can write).
- CIMD registration; optional consent-time read-only/read-write toggle.

## Stacking
Was developed stacked on `mcp-write-tools` (#690), itself on #689.
**Both have since merged to `main`**, so this branch was rebased onto
`main` (`git rebase --onto origin/main mcp-write-tools`) and targets
`main` directly.
This commit is contained in:
Víctor Falcón 2026-07-17 19:10:48 +02:00 committed by GitHub
parent 5d7b655111
commit 6d5f440727
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 1517 additions and 119 deletions

View File

@ -8,6 +8,12 @@ APP_LOCALE=en
APP_FALLBACK_LOCALE=en APP_FALLBACK_LOCALE=en
APP_FAKER_LOCALE=en_US APP_FAKER_LOCALE=en_US
# Passport OAuth signing keys for the MCP OAuth server. Leave blank to use the
# keys generated by `php artisan passport:keys` under storage/. Set both (PEM
# contents) to share one key pair across multiple app instances.
PASSPORT_PRIVATE_KEY=
PASSPORT_PUBLIC_KEY=
APP_MAINTENANCE_DRIVER=file APP_MAINTENANCE_DRIVER=file
# APP_MAINTENANCE_STORE=database # APP_MAINTENANCE_STORE=database

View File

@ -64,6 +64,9 @@ jobs:
- name: Generate Application Key - name: Generate Application Key
run: php artisan key:generate run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Tests - name: Tests
# --parallel splits the suite across workers via paratest. Each # --parallel splits the suite across workers via paratest. Each
# worker creates its own "testing_test_<N>" database; the mysql # worker creates its own "testing_test_<N>" database; the mysql
@ -144,6 +147,9 @@ jobs:
- name: Generate Application Key - name: Generate Application Key
run: php artisan key:generate run: php artisan key:generate
- name: Generate Passport Keys
run: php artisan passport:keys --force
- name: Browser Tests - name: Browser Tests
run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6 run: ./vendor/bin/pest --testsuite=Browser --ci --shard=${{ matrix.shard }}/6
env: env:

View File

@ -41,6 +41,7 @@ class McpTokenController extends Controller implements HasMiddleware
return Inertia::render('settings/mcp', [ return Inertia::render('settings/mcp', [
'tokens' => $this->tokensFor($request), 'tokens' => $this->tokensFor($request),
'serverUrl' => url('/mcp'), 'serverUrl' => url('/mcp'),
'oauthUrl' => url('/mcp/oauth'),
'subscribeUrl' => route('subscribe'), 'subscribeUrl' => route('subscribe'),
'newToken' => $request->session()->get('mcp_token'), 'newToken' => $request->session()->get('mcp_token'),
]); ]);

View File

@ -35,8 +35,8 @@ use Laravel\Mcp\Server\Tool;
#[Version('1.0.0')] #[Version('1.0.0')]
#[Instructions(<<<'MARKDOWN' #[Instructions(<<<'MARKDOWN'
Access to the authenticated user's Whisper Money finance data, for analysing Access to the authenticated user's Whisper Money finance data, for analysing
spending, cashflow and net worth and, with a read & write token, for editing spending, cashflow and net worth and, with write access, for editing that
that data. data.
- All amounts are integers in minor units (cents). Divide by 100 for a display value. - All amounts are integers in minor units (cents). Divide by 100 for a display value.
- Data is organised into "spaces" (the personal space and any shared spaces). - Data is organised into "spaces" (the personal space and any shared spaces).

View File

@ -10,14 +10,16 @@ use App\Models\Space;
use App\Models\Transaction; use App\Models\Transaction;
use App\Models\User; use App\Models\User;
use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Laravel\Mcp\Request; use Laravel\Mcp\Request;
use Laravel\Mcp\Response; use Laravel\Mcp\Response;
/** /**
* Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate * Base for every Whisper Money write tool. On top of the McpTool Pro-plan gate
* it requires the calling token to carry the `mcp:write` ability, so a * it gates write access: OAuth connections (Claude Desktop / ChatGPT) get
* read-only token can analyse data but never change it. * read+write, and Sanctum personal access tokens must carry the `mcp:write`
* ability, so a read-only PAT can analyse data but never change it.
* *
* Each concrete write tool must additionally carry the #[IsDestructive] * Each concrete write tool must additionally carry the #[IsDestructive]
* annotation. PHP attributes are not inherited, so the framework only reports * annotation. PHP attributes are not inherited, so the framework only reports
@ -27,7 +29,13 @@ abstract class WriteTool extends McpTool
{ {
protected function respond(Request $request, User $user): Response protected function respond(Request $request, User $user): Response
{ {
if (! $user->tokenCan('mcp:write')) { // Write access is granted to OAuth connections (Claude Desktop /
// ChatGPT, resolved via the `api` guard — the user approves the
// connection on the consent screen) and to Sanctum personal access
// tokens carrying the mcp:write ability. A read-only Sanctum token is
// rejected. Bank-connected data stays protected for both (see the
// writableAccount / transaction helpers below).
if (Auth::getDefaultDriver() !== 'api' && ! $user->tokenCan('mcp:write')) {
return Response::error('This token is read-only. Create a read & write token to make changes.'); return Response::error('This token is read-only. Create a read & write token to make changes.');
} }

View File

@ -15,6 +15,7 @@ use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
use Laravel\Cashier\Cashier; use Laravel\Cashier\Cashier;
use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract; use Laravel\Fortify\Contracts\RegisterResponse as RegisterResponseContract;
use Laravel\Passport\Passport;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
{ {
@ -55,5 +56,9 @@ class AppServiceProvider extends ServiceProvider
RateLimiter::for('emails', function (object $job): Limit { RateLimiter::for('emails', function (object $job): Limit {
return Limit::perSecond(30); return Limit::perSecond(30);
}); });
// Render the OAuth consent screen (Claude Desktop / ChatGPT connecting
// to the MCP server) with our own on-brand Blade view.
Passport::authorizationView(fn (array $parameters) => response()->view('mcp.authorize', $parameters));
} }
} }

View File

@ -20,6 +20,7 @@
"laravel/fortify": "^1.30", "laravel/fortify": "^1.30",
"laravel/framework": "^13.0", "laravel/framework": "^13.0",
"laravel/mcp": "^0.6.7", "laravel/mcp": "^0.6.7",
"laravel/passport": "^13.0",
"laravel/pennant": "^1.18", "laravel/pennant": "^1.18",
"laravel/sanctum": "^4.3", "laravel/sanctum": "^4.3",
"laravel/tinker": "^3.0", "laravel/tinker": "^3.0",

804
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically" "This file is @generated automatically"
], ],
"content-hash": "196f117744cd19cbd40b8b0fb6d2cdc2", "content-hash": "effcd211b8b23d89abfefa7550a43d7d",
"packages": [ "packages": [
{ {
"name": "aws/aws-crt-php", "name": "aws/aws-crt-php",
@ -391,6 +391,73 @@
}, },
"time": "2025-09-16T12:23:56+00:00" "time": "2025-09-16T12:23:56+00:00"
}, },
{
"name": "defuse/php-encryption",
"version": "v2.4.0",
"source": {
"type": "git",
"url": "https://github.com/defuse/php-encryption.git",
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/defuse/php-encryption/zipball/f53396c2d34225064647a05ca76c1da9d99e5828",
"reference": "f53396c2d34225064647a05ca76c1da9d99e5828",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"paragonie/random_compat": ">= 2",
"php": ">=5.6.0"
},
"require-dev": {
"phpunit/phpunit": "^5|^6|^7|^8|^9|^10",
"yoast/phpunit-polyfills": "^2.0.0"
},
"bin": [
"bin/generate-defuse-key"
],
"type": "library",
"autoload": {
"psr-4": {
"Defuse\\Crypto\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Hornby",
"email": "taylor@defuse.ca",
"homepage": "https://defuse.ca/"
},
{
"name": "Scott Arciszewski",
"email": "info@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "Secure PHP Encryption Library",
"keywords": [
"aes",
"authenticated encryption",
"cipher",
"crypto",
"cryptography",
"encrypt",
"encryption",
"openssl",
"security",
"symmetric key cryptography"
],
"support": {
"issues": "https://github.com/defuse/php-encryption/issues",
"source": "https://github.com/defuse/php-encryption/tree/v2.4.0"
},
"time": "2023-06-19T06:10:36+00:00"
},
{ {
"name": "dflydev/dot-access-data", "name": "dflydev/dot-access-data",
"version": "v3.0.3", "version": "v3.0.3",
@ -2208,6 +2275,81 @@
}, },
"time": "2026-04-15T08:30:42+00:00" "time": "2026-04-15T08:30:42+00:00"
}, },
{
"name": "laravel/passport",
"version": "v13.7.5",
"source": {
"type": "git",
"url": "https://github.com/laravel/passport.git",
"reference": "90053dc4ba681c076855779250109bb624f961f6"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/laravel/passport/zipball/90053dc4ba681c076855779250109bb624f961f6",
"reference": "90053dc4ba681c076855779250109bb624f961f6",
"shasum": ""
},
"require": {
"ext-json": "*",
"ext-openssl": "*",
"firebase/php-jwt": "^6.4|^7.0",
"illuminate/auth": "^11.35|^12.0|^13.0",
"illuminate/console": "^11.35|^12.0|^13.0",
"illuminate/container": "^11.35|^12.0|^13.0",
"illuminate/contracts": "^11.35|^12.0|^13.0",
"illuminate/cookie": "^11.35|^12.0|^13.0",
"illuminate/database": "^11.35|^12.0|^13.0",
"illuminate/encryption": "^11.35|^12.0|^13.0",
"illuminate/http": "^11.35|^12.0|^13.0",
"illuminate/support": "^11.35|^12.0|^13.0",
"league/oauth2-server": "^9.2",
"php": "^8.2",
"php-http/discovery": "^1.20",
"phpseclib/phpseclib": "^3.0",
"psr/http-factory-implementation": "*",
"symfony/console": "^7.1|^8.0",
"symfony/psr-http-message-bridge": "^7.1|^8.0"
},
"require-dev": {
"orchestra/testbench": "^9.15|^10.8|^11.0",
"phpstan/phpstan": "^2.0"
},
"type": "library",
"extra": {
"laravel": {
"providers": [
"Laravel\\Passport\\PassportServiceProvider"
]
}
},
"autoload": {
"psr-4": {
"Laravel\\Passport\\": "src/",
"Laravel\\Passport\\Database\\Factories\\": "database/factories/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Taylor Otwell",
"email": "taylor@laravel.com"
}
],
"description": "Laravel Passport provides OAuth2 server support to Laravel.",
"keywords": [
"laravel",
"oauth",
"passport"
],
"support": {
"issues": "https://github.com/laravel/passport/issues",
"source": "https://github.com/laravel/passport"
},
"time": "2026-04-16T14:00:29+00:00"
},
{ {
"name": "laravel/pennant", "name": "laravel/pennant",
"version": "v1.22.0", "version": "v1.22.0",
@ -2599,6 +2741,79 @@
}, },
"time": "2026-02-24T12:51:07+00:00" "time": "2026-02-24T12:51:07+00:00"
}, },
{
"name": "lcobucci/jwt",
"version": "5.6.0",
"source": {
"type": "git",
"url": "https://github.com/lcobucci/jwt.git",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/lcobucci/jwt/zipball/bb3e9f21e4196e8afc41def81ef649c164bca25e",
"reference": "bb3e9f21e4196e8afc41def81ef649c164bca25e",
"shasum": ""
},
"require": {
"ext-openssl": "*",
"ext-sodium": "*",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0"
},
"require-dev": {
"infection/infection": "^0.29",
"lcobucci/clock": "^3.2",
"lcobucci/coding-standard": "^11.0",
"phpbench/phpbench": "^1.2",
"phpstan/extension-installer": "^1.2",
"phpstan/phpstan": "^1.10.7",
"phpstan/phpstan-deprecation-rules": "^1.1.3",
"phpstan/phpstan-phpunit": "^1.3.10",
"phpstan/phpstan-strict-rules": "^1.5.0",
"phpunit/phpunit": "^11.1"
},
"suggest": {
"lcobucci/clock": ">= 3.2"
},
"type": "library",
"autoload": {
"psr-4": {
"Lcobucci\\JWT\\": "src"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"BSD-3-Clause"
],
"authors": [
{
"name": "Luís Cobucci",
"email": "lcobucci@gmail.com",
"role": "Developer"
}
],
"description": "A simple library to work with JSON Web Token and JSON Web Signature",
"keywords": [
"JWS",
"jwt"
],
"support": {
"issues": "https://github.com/lcobucci/jwt/issues",
"source": "https://github.com/lcobucci/jwt/tree/5.6.0"
},
"funding": [
{
"url": "https://github.com/lcobucci",
"type": "github"
},
{
"url": "https://www.patreon.com/lcobucci",
"type": "patreon"
}
],
"time": "2025-10-17T11:30:53+00:00"
},
{ {
"name": "league/commonmark", "name": "league/commonmark",
"version": "2.8.2", "version": "2.8.2",
@ -2788,6 +3003,65 @@
], ],
"time": "2022-12-11T20:36:23+00:00" "time": "2022-12-11T20:36:23+00:00"
}, },
{
"name": "league/event",
"version": "3.0.3",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/event.git",
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/event/zipball/ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
"reference": "ec38ff7ea10cad7d99a79ac937fbcffb9334c210",
"shasum": ""
},
"require": {
"php": ">=7.2.0",
"psr/event-dispatcher": "^1.0"
},
"provide": {
"psr/event-dispatcher-implementation": "1.0"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^2.16",
"phpstan/phpstan": "^0.12.45",
"phpunit/phpunit": "^8.5"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "3.0-dev"
}
},
"autoload": {
"psr-4": {
"League\\Event\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Frank de Jonge",
"email": "info@frenky.net"
}
],
"description": "Event package",
"keywords": [
"emitter",
"event",
"listener"
],
"support": {
"issues": "https://github.com/thephpleague/event/issues",
"source": "https://github.com/thephpleague/event/tree/3.0.3"
},
"time": "2024-09-04T16:06:53+00:00"
},
{ {
"name": "league/flysystem", "name": "league/flysystem",
"version": "3.32.0", "version": "3.32.0",
@ -2976,6 +3250,103 @@
], ],
"time": "2024-09-21T08:32:55+00:00" "time": "2024-09-21T08:32:55+00:00"
}, },
{
"name": "league/oauth2-server",
"version": "9.4.1",
"source": {
"type": "git",
"url": "https://github.com/thephpleague/oauth2-server.git",
"reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/thephpleague/oauth2-server/zipball/9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c",
"reference": "9d2f6fc0a0b5aa1bb02506971d3a4ecff2c6526c",
"shasum": ""
},
"require": {
"defuse/php-encryption": "^2.4",
"ext-json": "*",
"ext-openssl": "*",
"lcobucci/jwt": "^5.6",
"league/event": "^3.0",
"league/uri": "^7.8",
"php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0",
"psr/clock": "^1.0",
"psr/http-message": "^2.0",
"psr/http-server-middleware": "^1.0"
},
"replace": {
"league/oauth2server": "*",
"lncd/oauth2": "*"
},
"require-dev": {
"laminas/laminas-diactoros": "^3.8",
"paragonie/random_compat": "^9.99.100",
"php-parallel-lint/php-parallel-lint": "^1.4",
"phpstan/extension-installer": "^1.4.3",
"phpstan/phpstan": "^2.1.38",
"phpstan/phpstan-deprecation-rules": "^2.0",
"phpstan/phpstan-phpunit": "^2.0.12",
"phpstan/phpstan-strict-rules": "^2.0",
"phpunit/phpunit": "^11.5.50",
"roave/security-advisories": "dev-master",
"slevomat/coding-standard": "^8.27.1",
"squizlabs/php_codesniffer": "^4.0"
},
"type": "library",
"autoload": {
"psr-4": {
"League\\OAuth2\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Alex Bilbie",
"email": "hello@alexbilbie.com",
"homepage": "http://www.alexbilbie.com",
"role": "Developer"
},
{
"name": "Andy Millington",
"email": "andrew@noexceptions.io",
"homepage": "https://www.noexceptions.io",
"role": "Developer"
}
],
"description": "A lightweight and powerful OAuth 2.0 authorization and resource server library with support for all the core specification grants. This library will allow you to secure your API with OAuth and allow your applications users to approve apps that want to access their data from your API.",
"homepage": "https://oauth2.thephpleague.com/",
"keywords": [
"Authentication",
"api",
"auth",
"authorisation",
"authorization",
"oauth",
"oauth 2",
"oauth 2.0",
"oauth2",
"protect",
"resource",
"secure",
"server"
],
"support": {
"issues": "https://github.com/thephpleague/oauth2-server/issues",
"source": "https://github.com/thephpleague/oauth2-server/tree/9.4.1"
},
"funding": [
{
"url": "https://github.com/sephster",
"type": "github"
}
],
"time": "2026-06-25T15:24:07+00:00"
},
{ {
"name": "league/uri", "name": "league/uri",
"version": "7.8.1", "version": "7.8.1",
@ -3972,6 +4343,135 @@
}, },
"time": "2025-09-24T15:06:41+00:00" "time": "2025-09-24T15:06:41+00:00"
}, },
{
"name": "paragonie/random_compat",
"version": "v9.99.100",
"source": {
"type": "git",
"url": "https://github.com/paragonie/random_compat.git",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/paragonie/random_compat/zipball/996434e5492cb4c3edcb9168db6fbb1359ef965a",
"reference": "996434e5492cb4c3edcb9168db6fbb1359ef965a",
"shasum": ""
},
"require": {
"php": ">= 7"
},
"require-dev": {
"phpunit/phpunit": "4.*|5.*",
"vimeo/psalm": "^1"
},
"suggest": {
"ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes."
},
"type": "library",
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Paragon Initiative Enterprises",
"email": "security@paragonie.com",
"homepage": "https://paragonie.com"
}
],
"description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7",
"keywords": [
"csprng",
"polyfill",
"pseudorandom",
"random"
],
"support": {
"email": "info@paragonie.com",
"issues": "https://github.com/paragonie/random_compat/issues",
"source": "https://github.com/paragonie/random_compat"
},
"time": "2020-10-15T08:29:30+00:00"
},
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{ {
"name": "phpoption/phpoption", "name": "phpoption/phpoption",
"version": "1.9.5", "version": "1.9.5",
@ -4047,6 +4547,116 @@
], ],
"time": "2025-12-27T19:41:33+00:00" "time": "2025-12-27T19:41:33+00:00"
}, },
{
"name": "phpseclib/phpseclib",
"version": "3.0.55",
"source": {
"type": "git",
"url": "https://github.com/phpseclib/phpseclib.git",
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpseclib/phpseclib/zipball/db9744e6d47e742b1f974e965ad49bdd041105af",
"reference": "db9744e6d47e742b1f974e965ad49bdd041105af",
"shasum": ""
},
"require": {
"paragonie/constant_time_encoding": "^1|^2|^3",
"paragonie/random_compat": "^1.4|^2.0|^9.99.99",
"php": ">=5.6.1"
},
"require-dev": {
"phpunit/phpunit": "*"
},
"suggest": {
"ext-dom": "Install the DOM extension to load XML formatted public keys.",
"ext-gmp": "Install the GMP (GNU Multiple Precision) extension in order to speed up arbitrary precision integer arithmetic operations.",
"ext-libsodium": "SSH2/SFTP can make use of some algorithms provided by the libsodium-php extension.",
"ext-mcrypt": "Install the Mcrypt extension in order to speed up a few other cryptographic operations.",
"ext-openssl": "Install the OpenSSL extension in order to speed up a wide variety of cryptographic operations."
},
"type": "library",
"autoload": {
"files": [
"phpseclib/bootstrap.php"
],
"psr-4": {
"phpseclib3\\": "phpseclib/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Jim Wigginton",
"email": "terrafrost@php.net",
"role": "Lead Developer"
},
{
"name": "Patrick Monnerat",
"email": "pm@datasphere.ch",
"role": "Developer"
},
{
"name": "Andreas Fischer",
"email": "bantu@phpbb.com",
"role": "Developer"
},
{
"name": "Hans-Jürgen Petrich",
"email": "petrich@tronic-media.com",
"role": "Developer"
},
{
"name": "Graham Campbell",
"email": "graham@alt-three.com",
"role": "Developer"
}
],
"description": "PHP Secure Communications Library - Pure-PHP implementations of RSA, AES, SSH2, SFTP, X.509 etc.",
"homepage": "http://phpseclib.sourceforge.net",
"keywords": [
"BigInteger",
"aes",
"asn.1",
"asn1",
"blowfish",
"crypto",
"cryptography",
"encryption",
"rsa",
"security",
"sftp",
"signature",
"signing",
"ssh",
"twofish",
"x.509",
"x509"
],
"support": {
"issues": "https://github.com/phpseclib/phpseclib/issues",
"source": "https://github.com/phpseclib/phpseclib/tree/3.0.55"
},
"funding": [
{
"url": "https://github.com/terrafrost",
"type": "github"
},
{
"url": "https://www.patreon.com/phpseclib",
"type": "patreon"
},
{
"url": "https://tidelift.com/funding/github/packagist/phpseclib/phpseclib",
"type": "tidelift"
}
],
"time": "2026-06-14T23:24:10+00:00"
},
{ {
"name": "phpstan/phpdoc-parser", "name": "phpstan/phpdoc-parser",
"version": "2.3.2", "version": "2.3.2",
@ -4457,6 +5067,119 @@
}, },
"time": "2023-04-04T09:54:51+00:00" "time": "2023-04-04T09:54:51+00:00"
}, },
{
"name": "psr/http-server-handler",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-handler.git",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-handler/zipball/84c4fb66179be4caaf8e97bd239203245302e7d4",
"reference": "84c4fb66179be4caaf8e97bd239203245302e7d4",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side request handler",
"keywords": [
"handler",
"http",
"http-interop",
"psr",
"psr-15",
"psr-7",
"request",
"response",
"server"
],
"support": {
"source": "https://github.com/php-fig/http-server-handler/tree/1.0.2"
},
"time": "2023-04-10T20:06:20+00:00"
},
{
"name": "psr/http-server-middleware",
"version": "1.0.2",
"source": {
"type": "git",
"url": "https://github.com/php-fig/http-server-middleware.git",
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-fig/http-server-middleware/zipball/c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
"reference": "c1481f747daaa6a0782775cd6a8c26a1bf4a3829",
"shasum": ""
},
"require": {
"php": ">=7.0",
"psr/http-message": "^1.0 || ^2.0",
"psr/http-server-handler": "^1.0"
},
"type": "library",
"extra": {
"branch-alias": {
"dev-master": "1.0.x-dev"
}
},
"autoload": {
"psr-4": {
"Psr\\Http\\Server\\": "src/"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "PHP-FIG",
"homepage": "https://www.php-fig.org/"
}
],
"description": "Common interface for HTTP server-side middleware",
"keywords": [
"http",
"http-interop",
"middleware",
"psr",
"psr-15",
"psr-7",
"request",
"response"
],
"support": {
"issues": "https://github.com/php-fig/http-server-middleware/issues",
"source": "https://github.com/php-fig/http-server-middleware/tree/1.0.2"
},
"time": "2023-04-11T06:14:47+00:00"
},
{ {
"name": "psr/log", "name": "psr/log",
"version": "3.0.2", "version": "3.0.2",
@ -11939,85 +12662,6 @@
}, },
"time": "2025-11-29T19:12:34+00:00" "time": "2025-11-29T19:12:34+00:00"
}, },
{
"name": "php-http/discovery",
"version": "1.20.0",
"source": {
"type": "git",
"url": "https://github.com/php-http/discovery.git",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
"shasum": ""
},
"require": {
"composer-plugin-api": "^1.0|^2.0",
"php": "^7.1 || ^8.0"
},
"conflict": {
"nyholm/psr7": "<1.0",
"zendframework/zend-diactoros": "*"
},
"provide": {
"php-http/async-client-implementation": "*",
"php-http/client-implementation": "*",
"psr/http-client-implementation": "*",
"psr/http-factory-implementation": "*",
"psr/http-message-implementation": "*"
},
"require-dev": {
"composer/composer": "^1.0.2|^2.0",
"graham-campbell/phpspec-skip-example-extension": "^5.0",
"php-http/httplug": "^1.0 || ^2.0",
"php-http/message-factory": "^1.0",
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
"sebastian/comparator": "^3.0.5 || ^4.0.8",
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
},
"type": "composer-plugin",
"extra": {
"class": "Http\\Discovery\\Composer\\Plugin",
"plugin-optional": true
},
"autoload": {
"psr-4": {
"Http\\Discovery\\": "src/"
},
"exclude-from-classmap": [
"src/Composer/Plugin.php"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Márk Sági-Kazár",
"email": "mark.sagikazar@gmail.com"
}
],
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
"homepage": "http://php-http.org",
"keywords": [
"adapter",
"client",
"discovery",
"factory",
"http",
"message",
"psr17",
"psr7"
],
"support": {
"issues": "https://github.com/php-http/discovery/issues",
"source": "https://github.com/php-http/discovery/tree/1.20.0"
},
"time": "2024-10-02T11:20:13+00:00"
},
{ {
"name": "php-http/httplug", "name": "php-http/httplug",
"version": "2.4.1", "version": "2.4.1",

View File

@ -42,6 +42,11 @@ return [
'driver' => 'session', 'driver' => 'session',
'provider' => 'users', 'provider' => 'users',
], ],
'api' => [
'driver' => 'passport',
'provider' => 'users',
],
], ],
/* /*

55
config/mcp.php Normal file
View File

@ -0,0 +1,55 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Redirect Domains
|--------------------------------------------------------------------------
|
| These domains are the domains that OAuth clients are permitted to use
| for redirect URIs. Each domain should be specified with its scheme
| and host. Domains not in this list will raise validation errors.
|
| An "*" may be used to allow all domains.
|
*/
'redirect_domains' => [
// Anthropic's hosted callback for Claude Desktop / Claude web connectors.
'https://claude.ai',
// OpenAI's hosted callback for ChatGPT connectors.
'https://chatgpt.com',
],
/*
|--------------------------------------------------------------------------
| Allowed Custom Schemes
|--------------------------------------------------------------------------
|
| Native desktop OAuth clients like Cursor and VS Code use private-use URI
| schemes (RFC 8252) for redirect callbacks instead of standard schemes
| like HTTPS. Here, you may list which custom schemes you will allow.
|
*/
'custom_schemes' => [
// 'claude',
// 'cursor',
// 'vscode',
],
/*
|--------------------------------------------------------------------------
| Authorization Server
|--------------------------------------------------------------------------
|
| Here you may configure the OAuth authorization server issuer identifier
| per RFC 8414. This value appears in your protected resource and auth
| server metadata endpoints. When null, this defaults to `url('/')`.
|
*/
'authorization_server' => null,
];

48
config/passport.php Normal file
View File

@ -0,0 +1,48 @@
<?php
return [
/*
|--------------------------------------------------------------------------
| Passport Guard
|--------------------------------------------------------------------------
|
| Here you may specify which authentication guard Passport will use when
| authenticating users. This value should correspond with one of your
| guards that is already present in your "auth" configuration file.
|
*/
'guard' => 'web',
'middleware' => [],
/*
|--------------------------------------------------------------------------
| Encryption Keys
|--------------------------------------------------------------------------
|
| Passport uses encryption keys while generating secure access tokens for
| your application. By default, the keys are stored as local files but
| can be set via environment variables when that is more convenient.
|
*/
'private_key' => env('PASSPORT_PRIVATE_KEY'),
'public_key' => env('PASSPORT_PUBLIC_KEY'),
/*
|--------------------------------------------------------------------------
| Passport Database Connection
|--------------------------------------------------------------------------
|
| By default, Passport's models will utilize your application's default
| database connection. If you wish to use a different connection you
| may specify the configured name of the database connection here.
|
*/
'connection' => env('PASSPORT_CONNECTION'),
];

View File

@ -0,0 +1,39 @@
<?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('oauth_auth_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignUuid('user_id')->index();
$table->foreignUuid('client_id');
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_auth_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,41 @@
<?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('oauth_access_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignUuid('user_id')->nullable()->index();
$table->foreignUuid('client_id');
$table->string('name')->nullable();
$table->text('scopes')->nullable();
$table->boolean('revoked');
$table->timestamps();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_access_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,37 @@
<?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('oauth_refresh_tokens', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->char('access_token_id', 80)->index();
$table->boolean('revoked');
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_refresh_tokens');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,42 @@
<?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('oauth_clients', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->nullableUuidMorphs('owner');
$table->string('name');
$table->string('secret')->nullable();
$table->string('provider')->nullable();
$table->text('redirect_uris');
$table->text('grant_types');
$table->boolean('revoked');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_clients');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -0,0 +1,42 @@
<?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('oauth_device_codes', function (Blueprint $table) {
$table->char('id', 80)->primary();
$table->foreignUuid('user_id')->nullable()->index();
$table->foreignUuid('client_id')->index();
$table->char('user_code', 8)->unique();
$table->text('scopes');
$table->boolean('revoked');
$table->dateTime('user_approved_at')->nullable();
$table->dateTime('last_polled_at')->nullable();
$table->dateTime('expires_at')->nullable();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('oauth_device_codes');
}
/**
* Get the migration connection name.
*/
public function getConnection(): ?string
{
return $this->connection ?? config('passport.connection');
}
};

View File

@ -82,6 +82,16 @@ echo "MySQL is ready!"
echo "Running migrations..." echo "Running migrations..."
php artisan migrate --force php artisan migrate --force
# Ensure Passport signing keys exist (OAuth for MCP connectors). Keys provided
# via PASSPORT_PRIVATE_KEY/PASSPORT_PUBLIC_KEY env take precedence; otherwise
# generate them into storage/ (a persisted volume) so issued tokens survive
# restarts. NOTE: for a multi-instance deployment, provide the keys via env so
# every instance validates tokens with the same key.
if [ -z "$PASSPORT_PRIVATE_KEY" ] && [ ! -f /app/storage/oauth-private.key ]; then
echo "Generating Passport keys..."
php artisan passport:keys --force
fi
# Cache configuration # Cache configuration
echo "Caching configuration..." echo "Caching configuration..."
php artisan config:cache php artisan config:cache

View File

@ -2270,8 +2270,19 @@
"Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.", "Rotate a token if it leaks, or revoke it to cut off access.": "Rota un token si se filtra, o revócalo para cortar el acceso.",
"Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.", "Rotating gives you a new secret and cancels the old one. Anything using the current token stops working until you reconnect it with the new secret.": "Al rotar obtienes un secreto nuevo y se cancela el anterior. Lo que use el token actual dejará de funcionar hasta que lo reconectes con el nuevo secreto.",
"Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.", "Anything using this token loses access right away, and you cannot undo it.": "Cualquier cosa que use este token pierde el acceso al instante, y no se puede deshacer.",
"Here is your connection URL. Use it with a token you created above.": "Esta es tu URL de conexión. Úsala con un token que hayas creado arriba.", "Connect your AI assistant using the details for it below.": "Conecta tu asistente de IA usando los datos correspondientes de abajo.",
"Run this, using one of your tokens in place of <token>.": "Ejecuta esto, usando uno de tus tokens en lugar de <token>.", "Run this, using one of your tokens in place of <token>.": "Ejecuta esto, usando uno de tus tokens en lugar de <token>.",
"Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT", "Claude Desktop & ChatGPT": "Claude Desktop y ChatGPT",
"Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.": "Muy pronto. Estas apps inician sesión con OAuth, que todavía estamos construyendo, así que de momento un token no funciona con ellas. Usa Claude Code por ahora." "These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.": "Estas apps inician sesión en lugar de usar un token. Añade un conector personalizado que apunte a la URL de abajo y luego aprueba la conexión en la pantalla de Whisper Money que se abre. No hace falta ningún token.",
"Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.": "Las apps conectadas pueden leer, analizar y modificar tus datos (las cuentas conectadas a un banco siguen siendo de solo lectura). Tú apruebas la conexión en la pantalla de Whisper Money.",
"Connect to :app": "Conectar con :app",
"Connect :client to :app": "Conectar :client con :app",
":client is asking to read and make changes to your finance data.": ":client solicita leer y modificar tus datos financieros.",
"Signed in as": "Sesión iniciada como",
"Sends you back to": "Te devuelve a",
"This connection can:": "Esta conexión puede:",
"Read and analyse your transactions, balances, categories and spending.": "Leer y analizar tus transacciones, saldos, categorías y gastos.",
"Create, edit and delete transactions, categories, labels and automation rules.": "Crear, editar y eliminar transacciones, categorías, etiquetas y reglas de automatización.",
"Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.": "Las cuentas conectadas a un banco y sus transacciones siguen siendo de solo lectura. Puedes desconectarla cuando quieras desde la app conectada.",
"Connecting…": "Conectando…"
} }

View File

@ -56,6 +56,7 @@ interface TokenRow {
interface McpPageProps { interface McpPageProps {
tokens: TokenRow[]; tokens: TokenRow[];
serverUrl: string; serverUrl: string;
oauthUrl: string;
subscribeUrl: string; subscribeUrl: string;
newToken: string | null; newToken: string | null;
} }
@ -69,9 +70,8 @@ function formatDate(value: string | null): string {
} }
export default function Mcp() { export default function Mcp() {
const { tokens, serverUrl, subscribeUrl, newToken, auth } = usePage< const { tokens, serverUrl, oauthUrl, subscribeUrl, newToken, auth } =
SharedData & McpPageProps usePage<SharedData & McpPageProps>().props;
>().props;
const [, copy] = useClipboard(); const [, copy] = useClipboard();
const form = useForm<{ name: string; scope: 'read' | 'read_write' }>({ const form = useForm<{ name: string; scope: 'read' | 'read_write' }>({
@ -402,24 +402,39 @@ export default function Mcp() {
<CardTitle>{__('How to connect')}</CardTitle> <CardTitle>{__('How to connect')}</CardTitle>
<CardDescription> <CardDescription>
{__( {__(
'Here is your connection URL. Use it with a token you created above.', 'Connect your AI assistant using the details for it below.',
)} )}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="flex items-center gap-2"> <div className="space-y-1">
<code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm"> <h3 className="text-sm font-medium">
{serverUrl} {__('Claude Desktop & ChatGPT')}
</code> </h3>
<Button <p className="text-sm text-muted-foreground">
type="button" {__(
variant="outline" 'These apps sign in instead of using a token. Add a custom connector pointing at the URL below, then approve the connection on the Whisper Money screen that opens. No token needed.',
size="icon" )}
onClick={() => copyValue(serverUrl)} </p>
aria-label={__('Copy')} <div className="flex items-center gap-2 pt-1">
> <code className="flex-1 overflow-x-auto rounded-md bg-muted px-3 py-2 text-sm">
<Copy className="h-4 w-4" /> {oauthUrl}
</Button> </code>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => copyValue(oauthUrl)}
aria-label={__('Copy')}
>
<Copy className="h-4 w-4" />
</Button>
</div>
<p className="pt-1 text-sm text-muted-foreground">
{__(
'Connected apps can read, analyse and make changes to your data (bank-connected accounts stay read-only). You approve the connection on the Whisper Money screen.',
)}
</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
@ -435,17 +450,6 @@ export default function Mcp() {
{`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`} {`claude mcp add --transport http whisper-money ${serverUrl} --header "Authorization: Bearer <token>"`}
</code> </code>
</div> </div>
<div className="space-y-1">
<h3 className="text-sm font-medium">
{__('Claude Desktop & ChatGPT')}
</h3>
<p className="text-sm text-muted-foreground">
{__(
'Coming soon. These apps sign in with OAuth, which we are still building, so a token will not work with them yet. Use Claude Code for now.',
)}
</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@ -0,0 +1,165 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}" @class(['dark' => ($appearance ?? 'system') == 'dark'])>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{{-- Inline script to detect system dark mode preference and apply it immediately --}}
<script>
(function() {
const appearance = @json($appearance ?? 'system');
if (appearance === 'system') {
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (prefersDark) {
document.documentElement.classList.add('dark');
}
}
})();
</script>
<style>
html {
background-color: oklch(1 0 0);
}
html.dark {
background-color: oklch(0.145 0 0);
}
</style>
<title>{{ __('Connect to :app', ['app' => config('app.name', 'Whisper Money')]) }}</title>
<link rel="icon" type="image/png" href="/favicon/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/svg+xml" href="/favicon/favicon.svg" />
<link rel="shortcut icon" href="/favicon/favicon.ico" />
<link rel="apple-touch-icon" sizes="180x180" href="/favicon/apple-touch-icon.png" />
<link rel="manifest" href="/favicon/site.webmanifest" />
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=instrument-sans:400,500,600" rel="stylesheet" />
@vite(['resources/css/app.css'])
</head>
<body class="font-sans antialiased bg-background text-foreground">
@php
$redirectHost = parse_url($request->get('redirect_uri', ''), PHP_URL_HOST);
@endphp
<div class="min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<!-- Card Container -->
<div class="rounded-lg border bg-card text-card-foreground shadow-sm">
<!-- Header -->
<div class="flex flex-col space-y-1.5 p-6">
<div class="flex items-center justify-center mb-4">
<!-- Shield Icon -->
<svg class="h-12 w-12 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.618 5.984A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.031 9-11.622 0-1.042-.133-2.052-.382-3.016z"></path>
</svg>
</div>
<h3 class="text-2xl font-semibold leading-none tracking-tight text-center">
{{ __('Connect :client to :app', ['client' => $client->name, 'app' => config('app.name', 'Whisper Money')]) }}
</h3>
<p class="text-sm text-muted-foreground text-center">
{{ __(':client is asking to read and make changes to your finance data.', ['client' => $client->name]) }}
</p>
</div>
<!-- Content -->
<div class="p-6 pt-0 space-y-4">
<!-- User Info -->
<div class="rounded-lg border p-4 bg-muted/50">
<p class="text-sm text-muted-foreground mb-2">{{ __('Signed in as') }}</p>
<p class="font-medium">{{ $user->email }}</p>
@if($redirectHost)
<p class="text-sm text-muted-foreground mt-3 mb-1">{{ __('Sends you back to') }}</p>
<p class="font-medium break-all">{{ $redirectHost }}</p>
@endif
</div>
<!-- What the connection can and cannot do -->
<div class="space-y-2">
<p class="text-sm font-medium">{{ __('This connection can:') }}</p>
<ul class="space-y-2">
<li class="flex items-start gap-2">
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
</div>
<span class="text-sm text-muted-foreground">
{{ __('Read and analyse your transactions, balances, categories and spending.') }}
</span>
</li>
<li class="flex items-start gap-2">
<div class="rounded-full bg-primary/10 p-1 mt-0.5">
<div class="h-1.5 w-1.5 rounded-full bg-primary"></div>
</div>
<span class="text-sm text-muted-foreground">
{{ __('Create, edit and delete transactions, categories, labels and automation rules.') }}
</span>
</li>
</ul>
<p class="text-sm text-muted-foreground pt-1">
{{ __('Bank-connected accounts and their transactions stay read-only. You can disconnect it at any time from the connected app.') }}
</p>
</div>
</div>
<!-- Footer With Buttons -->
<div class="flex items-center p-6 pt-0 gap-3">
<!-- Deny Form -->
<form method="POST" action="{{ route('passport.authorizations.deny') }}" class="flex-1">
@csrf
@method('DELETE')
<input type="hidden" name="state" value="">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 border border-input bg-background hover:bg-accent hover:text-accent-foreground h-10 px-4 py-2 w-full">
<svg class="mr-2 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
{{ __('Cancel') }}
</button>
</form>
<!-- Approve Form -->
<form method="POST" action="{{ route('passport.authorizations.approve') }}" class="flex-1" id="authorizeForm">
@csrf
<input type="hidden" name="state" value="">
<input type="hidden" name="client_id" value="{{ $client->id }}">
<input type="hidden" name="auth_token" value="{{ $authToken }}">
<button type="submit" class="inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 bg-primary text-primary-foreground hover:bg-primary/90 h-10 px-4 py-2 w-full" id="authorizeButton">
<span id="authorizeText">{{ __('Connect') }}</span>
<svg id="loadingSpinner" class="animate-spin -ml-1 mr-3 h-4 w-4 text-white hidden" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('authorizeForm');
const button = document.getElementById('authorizeButton');
const authorizeText = document.getElementById('authorizeText');
const loadingSpinner = document.getElementById('loadingSpinner');
form.addEventListener('submit', function(e) {
// Show loading state...
button.disabled = true;
authorizeText.textContent = '{{ __('Connecting…') }}';
loadingSpinner.classList.remove('hidden');
});
});
</script>
</body>
</html>

View File

@ -8,12 +8,29 @@ use Laravel\Mcp\Facades\Mcp;
| MCP Routes | MCP Routes
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
| |
| Remote (streamable HTTP) MCP server. Authenticated with Sanctum personal | Remote (streamable HTTP) MCP server, exposed on two endpoints:
| access tokens carrying an `mcp:read` ability, throttled per user. The |
| Pro-plan gate is enforced inside each tool so a lapsed subscription stops | - `/mcp` is authenticated with Sanctum personal access tokens carrying an
| working without the user having to revoke their token. | `mcp:read` ability (Claude Code, static bearer token, read/read_write via
| abilities). Left completely unchanged.
| - `/mcp/oauth` is authenticated with OAuth 2.1 (Authorization Code + PKCE)
| for clients that sign in rather than paste a token Claude Desktop / web
| and ChatGPT connectors. `Mcp::oauthRoutes()` registers the RFC 8414 / 9728
| discovery endpoints, the RFC 7591 DCR endpoint and the `mcp:use` Passport
| scope; the package's AddWwwAuthenticateHeader middleware then turns an
| unauthenticated request into the mandatory 401 bootstrap challenge.
|
| OAuth connections carry only the single `mcp:use` scope, so `tokenCan`
| reports no `mcp:write` ability and the write tools stay read-only for them
| (see WriteTool). The Pro-plan gate is enforced inside each tool under either
| guard, so a lapsed subscription stops working without revoking access.
| |
*/ */
Mcp::oauthRoutes();
Mcp::web('/mcp', WhisperMoneyServer::class) Mcp::web('/mcp', WhisperMoneyServer::class)
->middleware(['auth:sanctum', 'abilities:mcp:read', 'throttle:60,1']); ->middleware(['auth:sanctum', 'abilities:mcp:read', 'throttle:60,1']);
Mcp::web('/mcp/oauth', WhisperMoneyServer::class)
->middleware(['auth:api', 'throttle:60,1']);

View File

@ -0,0 +1,205 @@
<?php
use App\Models\Label;
use App\Models\User;
use Illuminate\Support\Str;
use Laravel\Passport\ClientRepository;
use function Pest\Laravel\get;
use function Pest\Laravel\postJson;
use function Pest\Laravel\withHeaders;
/**
* A hosted-callback redirect URI inside the config('mcp.redirect_domains')
* allowlist (Anthropic's Claude connector callback).
*/
const CLAUDE_CALLBACK = 'https://claude.ai/api/mcp/auth_callback';
/**
* Generate a PKCE (verifier, S256 challenge) pair.
*
* @return array{0: string, 1: string}
*/
function pkcePair(): array
{
$verifier = Str::random(64);
$challenge = rtrim(strtr(base64_encode(hash('sha256', $verifier, true)), '+/', '-_'), '=');
return [$verifier, $challenge];
}
/**
* Drive the full Authorization Code + PKCE flow for the user and return the
* issued bearer access token, exactly as Claude/ChatGPT would obtain one:
* register a public client, authorize with a code challenge, approve on the
* consent screen, then exchange the code + verifier at the token endpoint.
*
* @param list<string> $scopes
*/
function issueOAuthToken(User $user, array $scopes = ['mcp:use']): string
{
$client = app(ClientRepository::class)->createAuthorizationCodeGrantClient(
name: 'Claude',
redirectUris: [CLAUDE_CALLBACK],
confidential: false,
);
[$verifier, $challenge] = pkcePair();
// 1. Authorize: renders the consent screen and primes the session.
$authorize = test()->actingAs($user)->get('/oauth/authorize?'.http_build_query([
'client_id' => $client->id,
'redirect_uri' => CLAUDE_CALLBACK,
'response_type' => 'code',
'scope' => implode(' ', $scopes),
'state' => 'state-token',
'code_challenge' => $challenge,
'code_challenge_method' => 'S256',
]))->assertOk();
// The consent form carries the single-use auth token we must echo back.
preg_match('/name="auth_token" value="([^"]+)"/', $authorize->getContent(), $matches);
$authToken = $matches[1] ?? null;
expect($authToken)->not->toBeNull();
// 2. Approve: redirects back to the client with the authorization code.
$approve = test()->actingAs($user)->post('/oauth/authorize', [
'auth_token' => $authToken,
'client_id' => $client->id,
'state' => 'state-token',
])->assertRedirect();
parse_str((string) parse_url($approve->headers->get('Location'), PHP_URL_QUERY), $redirect);
expect($redirect['code'] ?? null)->not->toBeNull();
// 3. Exchange the code + verifier for a token (public client, no secret).
$token = test()->post('/oauth/token', [
'grant_type' => 'authorization_code',
'client_id' => $client->id,
'redirect_uri' => CLAUDE_CALLBACK,
'code_verifier' => $verifier,
'code' => $redirect['code'],
])->assertOk();
return $token->json('access_token');
}
/*
|--------------------------------------------------------------------------
| Discovery (RFC 9728 / RFC 8414)
|--------------------------------------------------------------------------
*/
it('serves protected resource metadata for the OAuth MCP endpoint', function () {
$response = get('/.well-known/oauth-protected-resource/mcp/oauth')->assertOk();
expect($response->json('resource'))->toEndWith('/mcp/oauth');
expect($response->json('authorization_servers'))->not->toBeEmpty();
expect($response->json('scopes_supported'))->toBe(['mcp:use']);
});
it('serves authorization server metadata advertising PKCE and the mcp:use scope', function () {
$json = get('/.well-known/oauth-authorization-server')->assertOk()->json();
expect($json['response_types_supported'])->toBe(['code']);
expect($json['code_challenge_methods_supported'])->toBe(['S256']);
expect($json['scopes_supported'])->toBe(['mcp:use']);
expect($json['grant_types_supported'])->toBe(['authorization_code', 'refresh_token']);
expect($json['authorization_endpoint'])->toContain('oauth/authorize');
expect($json['token_endpoint'])->toContain('oauth/token');
expect($json['registration_endpoint'])->toContain('oauth/register');
});
/*
|--------------------------------------------------------------------------
| 401 bootstrap challenge (mandatory for Claude / ChatGPT)
|--------------------------------------------------------------------------
*/
it('returns a 401 bootstrap challenge for an unauthenticated OAuth MCP request', function () {
$response = postJson('/mcp/oauth', ['jsonrpc' => '2.0', 'id' => 1, 'method' => 'tools/list']);
$response->assertUnauthorized();
$header = $response->headers->get('WWW-Authenticate');
expect($header)->toContain('Bearer');
expect($header)->toContain('resource_metadata=');
expect($header)->toContain('/.well-known/oauth-protected-resource/mcp/oauth');
});
/*
|--------------------------------------------------------------------------
| Dynamic Client Registration (RFC 7591)
|--------------------------------------------------------------------------
*/
it('registers a public PKCE client via dynamic client registration', function () {
$response = postJson('/oauth/register', [
'client_name' => 'Claude',
'redirect_uris' => [CLAUDE_CALLBACK],
])->assertOk()->assertJson([
'token_endpoint_auth_method' => 'none',
'scope' => 'mcp:use',
]);
expect($response->json('client_id'))->not->toBeEmpty();
expect($response->json('redirect_uris'))->toContain(CLAUDE_CALLBACK);
});
it('rejects a DCR redirect URI outside the allowlist', function () {
postJson('/oauth/register', [
'client_name' => 'Evil',
'redirect_uris' => ['https://evil.example.com/callback'],
])->assertStatus(400)->assertJson(['error' => 'invalid_redirect_uri']);
});
/*
|--------------------------------------------------------------------------
| End-to-end Authorization Code + PKCE flow
|--------------------------------------------------------------------------
*/
it('authenticates a full Authorization Code + PKCE flow and serves read tools', function () {
$user = User::factory()->create();
$token = issueOAuthToken($user);
expect($token)->not->toBeEmpty();
withHeaders(['Authorization' => "Bearer {$token}"])
->postJson('/mcp/oauth', [
'jsonrpc' => '2.0',
'id' => 2,
'method' => 'tools/call',
'params' => ['name' => 'list_spaces', 'arguments' => (object) []],
])
->assertOk()
->assertSee('Personal', false);
});
/*
|--------------------------------------------------------------------------
| Write access over OAuth (Claude Desktop / ChatGPT connections can write)
|--------------------------------------------------------------------------
*/
it('lets an OAuth connection use write tools', function () {
$user = User::factory()->create();
$token = issueOAuthToken($user);
withHeaders(['Authorization' => "Bearer {$token}"])
->postJson('/mcp/oauth', [
'jsonrpc' => '2.0',
'id' => 3,
'method' => 'tools/call',
'params' => [
'name' => 'create_label',
'arguments' => ['name' => 'Groceries', 'color' => 'blue'],
],
])
->assertOk()
->assertSee('Groceries', false);
$label = Label::query()->where('user_id', $user->id)->first();
expect($label)->not->toBeNull();
expect($label->name)->toBe('Groceries');
});

View File

@ -5,5 +5,11 @@ ROOT_PATH=$1
cp "$ROOT_PATH/.env" .env cp "$ROOT_PATH/.env" .env
cp -r "$ROOT_PATH/storage/keys" ./storage/keys cp -r "$ROOT_PATH/storage/keys" ./storage/keys
# Passport OAuth signing keys (for the MCP OAuth server). Reuse the main repo's
# keys if present, otherwise generate a fresh pair after deps are installed.
cp "$ROOT_PATH"/storage/oauth-*.key ./storage/ 2>/dev/null || true
bun i bun i
composer install composer install
[ -f ./storage/oauth-private.key ] || php artisan passport:keys