danwel - MCP Server



A Model Context Protocol server that exposes the authenticated user's workspace
to AI clients (Claude, Claude Code, Cursor, …), protected by OAuth 2.1.

---

Table of Contents



1. [Overview](#overview)
2. [Authentication](#authentication)
3. [Tools](#tools)
4. [Organization Scoping](#organization-scoping)
5. [Adding a Tool](#adding-a-tool)
6. [Local Development](#local-development)
7. [Deployment](#deployment)

---

Overview



Built on [laravel/mcp](https://laravel.com/docs/12.x/mcp). The server, its tools
and its routes are:

| Thing | Where |
| --- | --- |
| Endpoint | POST /mcp |
| Route definition | routes/ai.php |
| Server | app/Mcp/Servers/DanwelServer.php |
| Tools | app/Mcp/Tools/ |
| OAuth user model | app/Models/OAuthUser.php |
| Config | config/mcp.php |
| Tests | tests/Feature/Mcp/ |

Tools read clients and projects, and read, create and edit entries, time
blocks and goals. Nothing can be deleted through MCP.

---

Authentication



OAuth 2.1 with authorization code + PKCE, issued by [Laravel Passport]
(https://laravel.com/docs/12.x/passport) through the api guard. OAuth 2.1 is
what the MCP specification documents and what remote clients such as claude.ai
expect, which is why the MCP server uses it while /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 by
Mcp::oauthRoutes()).
2. POST /oauth/register — dynamic client registration. Redirect URIs are
validated against redirect_domains in config/mcp.php; a client
registering a URI outside that list is rejected with invalid_redirect_uri.
Add a domain there before onboarding a new client.
3. GET /oauth/authorize — the user signs in normally and approves the
mcp:use scope on the consent screen (resources/views/mcp/authorize.blade.php,
wired up by Passport::authorizationView() in AppServiceProvider).
PKCE must use code_challenge_method=S256: league/oauth2-server would also
accept plain, and default to it when no method is sent, so
RequirePkceS256 (registered through config/passport.php) refuses
anything else with 400 invalid_request.
4. POST /oauth/token — code exchange, then POST /mcp with
Authorization: Bearer .

Access tokens live 7 days, refresh tokens 30 days
(AppServiceProvider::boot()). Tokens are listed per client in the
oauth_access_tokens table and can be revoked there.

Why OAuthUser exists



Passport and Sanctum both ship a HasApiTokens trait that defines tokens(),
createToken() and tokenCan(), and /api/* plus the mobile app depend on
Sanctum's. Passport additionally requires a user provider whose driver is plain
eloquent, 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 type
hints keep working) and implements Passport's OAuthenticatable by hand. It
also overrides getForeignKey(), joiningTableSegment() and getMorphClass(),
because Eloquent would otherwise derive o_auth_user_id columns and an
o_auth_user_organization pivot from the subclass name. Sanctum is untouched.

One trap to know about



Passport's TokenGuard can also authenticate from a laravel_token cookie,
and that path attaches a TransientToken, which reports **every** scope as
granted. It is inert here because the cookie is only ever issued by Passport's
CreateFreshApiToken middleware, which this application does not register (and
the path CSRF-checks the request anyway). If you ever add that middleware for an
SPA, scopes:mcp:use on /mcp silently stops restricting anything — gate on
something else at that point.

---

Tools



| Tool | Arguments | Returns |
| --- | --- | --- |
| 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 |

The write tools apply the same validation as the matching /api endpoints and
go through the same code paths, so side effects match the app: entries sync to
GitHub (GitHubSyncService), time blocks to Google Calendar and Moneybird
(TimeBlockService). On update, only the arguments given change, and null
clears a nullable link such as client_id.

Time blocks are stored in UTC. The tools return UTC, and on input a time with an
offset is converted to UTC while a time without one is read in the user's
timezone. TimeBlockService stores whatever Carbon instance it receives
without converting, so this normalisation in HandlesTimeBlocks matters.

All responses are structured JSON (Response::structured()), so clients get
parseable data rather than prose.

---

Organization Scoping



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 actually
restricts the endpoint to tokens issued for this purpose, and verified keeps
MCP in line with the web app, whose routes are all behind ['auth', 'verified'].


**This is load-bearing.** MCP routes are registered outside the web and api
middleware groups, so SetTenantContext does not apply automatically. Without
it TenantContext is never populated, and the BelongsToOrganization global
scope then adds no where organization_id = ? clause at all — the tools would
read every organization's records. tests/Feature/Mcp/McpServerTest.php covers
this end to end over HTTP; keep that test if you touch the route.

The same hole opens when tenant runs but finds nothing: a user with no
organization at all (for example one just detached from their last, whose token
is still valid). tenant.required (RequireTenantContext) refuses those
requests with a 403; McpEndToEndTest covers it.

Role rules apply on top of organization scoping, as they do everywhere else:
list-entries goes through Entry::visibleTo() and the single-entry tools
through EntryPolicy (members only see and change entries they created), and
the time block tools use TimeBlockService and TimeBlockPolicy, so only
owners and admins see and change the organization-wide calendar. Goals have no
policy in the app and are shared by the whole organization, so the goal tools
only rely on organization scoping.

---

Adding a Tool



``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
    support.