---
---
POST /mcp |routes/ai.php |app/Mcp/Servers/DanwelServer.php |app/Mcp/Tools/ |app/Models/OAuthUser.php |config/mcp.php |tests/Feature/Mcp/ |---
api guard. OAuth 2.1 is/api/* stays on Sanctum.The flow an AI client follows:
1.GET /.well-known/oauth-protected-resource and/.well-known/oauth-authorization-server — discovery (registered byMcp::oauthRoutes()).POST /oauth/register — dynamic client registration. Redirect URIs areredirect_domains in config/mcp.php; a clientinvalid_redirect_uri.GET /oauth/authorize — the user signs in normally and approves themcp:use scope on the consent screen (resources/views/mcp/authorize.blade.php,Passport::authorizationView() in AppServiceProvider).code_challenge_method=S256: league/oauth2-server would alsoplain, and default to it when no method is sent, soRequirePkceS256 (registered through config/passport.php) refuses400 invalid_request.POST /oauth/token — code exchange, then POST /mcp withAuthorization: Bearer .AppServiceProvider::boot()). Tokens are listed per client in theoauth_access_tokens table and can be revoked there.HasApiTokens trait that defines tokens(),createToken() and tokenCan(), and /api/* plus the mobile app depend oneloquent, which the application's webauthn-backed users provider is not.So the OAuth guard gets its own model and provider:
// config/auth.php
'api' => ['driver' => 'passport', 'provider' => 'oauth_users'],
'oauth_users' => ['driver' => 'eloquent', 'model' => App\Models\OAuthUser::class],OAuthUser extends User (same users table, same relations, so ?User typeOAuthenticatable by hand. ItgetForeignKey(), joiningTableSegment() and getMorphClass(),o_auth_user_id columns and ano_auth_user_organization pivot from the subclass name. Sanctum is untouched.TokenGuard can also authenticate from a laravel_token cookie,TransientToken, which reports **every** scope asCreateFreshApiToken middleware, which this application does not register (andscopes:mcp:use on /mcp silently stops restricting anything — gate on---
list-clients | search, include_inactive, limit | Clients with hourly rate and health score |list-projects | client_id, search, include_inactive, limit | Projects with their client |list-entries | type, status, project_id, client_id, search, include_body, limit | Tasks, notes, documents and ideas, with their body unless include_body is false |get-entry | id | One entry with its body, comments, labels and subtasks |create-entry | type, title, body, status, priority, time_estimate, parent_id, client_id, project_id | The new entry |update-entry | id plus any field of create-entry | The updated entry |list-time-blocks | start_date, end_date (default: current week, at most 93 days) | Time blocks plus total minutes and income |create-time-block | start_time, end_time, title, description, entry_id, client_id, project_id, is_billable | The new block |update-time-block | id plus any field of create-time-block | The updated block |list-goals | status (incl. active), priority, client_id, project_id, limit | Goals with target date and overdue flag |create-goal | title, description, priority, target_date, client_id, project_id | The new goal |update-goal | id plus any field of create-goal, and status | The updated goal |/api endpoints andGitHubSyncService), time blocks to Google Calendar and MoneybirdTimeBlockService). On update, only the arguments given change, and nullclient_id.timezone. TimeBlockService stores whatever Carbon instance it receivesHandlesTimeBlocks matters.Response::structured()), so clients get---
routes/ai.php registers the server with the tenant middleware:Mcp::web('/mcp', DanwelServer::class)
->middleware(['auth:api', 'scopes:mcp:use', 'verified', 'tenant', 'tenant.required', 'throttle:mcp']);auth:api only proves the token is valid, so scopes:mcp:use is what actuallyverified keeps['auth', 'verified'].web and apiSetTenantContext does not apply automatically. WithoutTenantContext is never populated, and the BelongsToOrganization globalwhere organization_id = ? clause at all — the tools wouldtests/Feature/Mcp/McpServerTest.php coverstenant runs but finds nothing: a user with notenant.required (RequireTenantContext) refuses thoseMcpEndToEndTest covers it.list-entries goes through Entry::visibleTo() and the single-entry toolsEntryPolicy (members only see and change entries they created), andTimeBlockService and TimeBlockPolicy, so only---
shell
php artisan make:mcp-tool ListInvoicesTool
Then register it in DanwelServer::$tools. Notes:
• Give the tool an explicit #[Name('list-invoices')]. Without it the name is
the kebab-cased class name including the suffix (list-invoices-tool).
• handle() returns ResponseFactory when you use Response::structured(),
not Response.
• The #[Description] attribute is how the model decides when to call the
tool. Say when to use it and what the arguments mean, not just what it does.
• Reuse existing services and query scopes rather than writing new queries, so
visibility rules stay in one place.---
Local Development
shell
php artisan mcp:inspector mcp
`
This launches the MCP Inspector against the registered server and prints the
client settings. Because /mcp is behind auth:api, the Inspector needs an
Authorization: Bearer header — walk through the OAuth flow, or create
a token manually.---
Deployment
1. Run migrations — Passport adds the oauth_* tables.
2. Run php artisan passport:keys **once** on the server. The keys land in
storage/ (gitignored, and persisted across deploys since the source is
bind-mounted). Regenerating them invalidates every issued token, which forces
every connected client to re-authorize. Alternatively, publish
config/passport.php and supply PASSPORT_PRIVATE_KEY /
PASSPORT_PUBLIC_KEY as environment variables.
3. Check redirect_domains in config/mcp.php` covers the clients you intend to