Remote MCP server with OAuth (Authelia) so Claude Code can use Tinker Tickets as the signed-in user #111
Closed
opened 2026-09-24 13:03:42 -04:00 by jared
·
6 comments
No Branch/Tag Specified
main
development
fix/comment-markdown-persist-18
deploy-2026.09.24-258
deploy-2026.09.24-254
deploy-2026.09.12-242
deploy-2026.09.12-238
deploy-2026.09.12-234
deploy-2026.09.12-230
deploy-2026.09.12-226
deploy-2026.09.12-222
deploy-2026.09.11-218
deploy-2026.09.11-214
deploy-2026.09.11-210
deploy-2026.09.11-206
deploy-2026.09.11-202
deploy-2026.09.11-198
deploy-2026.09.11-194
deploy-2026.09.11-190
deploy-2026.09.11-186
deploy-2026.09.09-182
deploy-2026.09.09-178
deploy-2026.09.08-172
deploy-2026.09.01-163
deploy-2026.08.08-155
deploy-2026.08.08-151
deploy-2026.08.08-147
deploy-2026.08.08-143
deploy-2026.08.08-139
deploy-2026.07.15-130
deploy-2026.07.15-122
deploy-2026.07.11-107
deploy-2026.06.30-96
deploy-2026.06.30-92
deploy-2026.06.30-88
deploy-2026.06.30-84
deploy-2026.06.30-80
deploy-2026.06.30-76
deploy-2026.06.30-72
deploy-2026.06.30-68
deploy-2026.04.29-49
deploy-2026.04.29-41
deploy-2026.04.18-35
deploy-2026.04.16-31
deploy-2026.04.16-27
deploy-2026.04.16-23
deploy-2026.04.16-11
deploy-2026.04.14-9
Labels
Clear labels
api
bug
concurrency
config
data-integrity
dead-code
documentation
duplicate
enhancement
help wanted
invalid
needs-decision
notifications
performance
priority/docs
priority/high
priority/low
priority/medium
question
rate-limiting
reliability
security
ux
wontfix
workflow
Bearer/internal API surface
Something is not working
Race condition / concurrency bug
Configuration / deployment default
Data correctness / schema / integrity issue
Unused / dead code cleanup
README / docs accuracy
This issue or pull request already exists
New feature
Need some help
Something is wrong
Needs a maintainer decision, not clearly a bug
Matrix / in-app notification bug
Performance or resource-usage concern
Documentation-only gap
High-severity / high-impact issue
Low-severity / cosmetic issue
Medium-severity issue
More information is needed
Rate limiting behavior
Reliability / error-handling gap
Security or access-control impact
User-facing UX/functional bug
This won't be fixed
Ticket status workflow engine
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: LotusGuild/tinker_tickets#111
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Goal
Let Claude Code (and other MCP clients) work with Tinker Tickets directly through a remote MCP server with OAuth sign-in, so no one has to create and hand out temporary API keys. A user signs in once through Authelia, and every MCP action runs as that user: same ticket visibility, same permissions, and attributed to them in the timeline and audit log.
Why OAuth instead of a static API key
Rough shape (to be confirmed by research)
https://t.lotusguild.org/mcp, acting as the OAuth resource server.claude mcp add --transport http ...and signs in through the browser via/mcp.Out of scope for now
Research findings will be posted as comments below.
Research findings (no code yet)
TL;DR
It's feasible with what we already run. Authelia can be the authorization server as-is (v4.39.20, OIDC already enabled). Tinker Tickets becomes the OAuth resource server with an
/mcpendpoint. Claude Code signs in through a pre-registered public client in Authelia, because Authelia doesn't support dynamic client registration. There is one version trap to watch for Authelia upgrades (below).1. What the MCP spec requires (spec version 2026-07-28)
/.well-known/oauth-protected-resource) naming Authelia as its authorization server. MUST answer unauthenticated requests with401+WWW-Authenticate: Bearer resource_metadata="..."(SHOULD also includescope=). MUST validate that every token was issued for this server (audience check, RFC 8707) and reject anything else with401. MUST NOT pass tokens through to other services. Insufficient scope →403+error="insufficient_scope".resourceparameter (= our canonical URL) on both the authorize and token requests, whether or not the authorization server supports it.2. Claude Code support
/mcporclaude mcp login <name>, plus automatic token refresh (keychain/credentials file).claude mcp add --transport http --client-id tinker-tickets-mcp --callback-port <PORT> tinker https://t.lotusguild.org/mcp, where the redirect URI registered in Authelia ishttp://localhost:<PORT>/callback(exact match,localhostnot127.0.0.1).3. Authelia as the authorization server
issin auth responseaccess_token_signed_response_alg(v4.38+)preferred_username,groups)claims_policies.<name>.access_tokentickets:read,tickets:write)scopesdefinitionsresourceparameterinvalid_target; authelia#12970, #13113). Fixed in v4.39.23 (PR #12973). Rule: skip 4.39.21/22 when upgrading; go straight to ≥4.39.23.Sketch of the Authelia client (for review, not applied):
4. Tinker Tickets side (the resource server)
Remote-User/Remote-Groupsheaders (middleware/AuthMiddleware.php). The MCP endpoint would do the same from the token'spreferred_username+groupsclaims, reusing the same user sync and admin-group logic. That way MCP actions use the user's real visibility, admin status, and attribution. (Authelia'ssubis expected to be an opaque ID rather than the username, hence the explicitpreferred_usernameclaim. To confirm during implementation.)iss,aud == https://t.lotusguild.org/mcp,exp/nbf, and the required scope per tool. PHP's built-inopenssl_verifyis enough; no library strictly needed./mcpand/.well-known/oauth-protected-resourcemust be exempt from Authelia forward-auth in NPM, the same way the Bearer API endpoints already are. Otherwise the client gets an HTML login redirect instead of the 401 challenge.modelcontextprotocol/php-sdk, maintained with the PHP Foundation): supports Streamable HTTP, both protocol eras including the stateless 2026-07-28 revision, and has authorization helpers. But the project has no Composer today, so this means addingcomposer.json+vendor/and acomposer installstep intinker_deploy.sh(it currently just doesgit reset --hard).Mcp-Method/Mcp-Nameheaders) and have to track spec changes ourselves.5. Proposed tools (first cut)
search_tickets,get_ticket(+ comments),create_ticket,add_comment,update_status(workflow-validated, comment when required),assign_ticket. Read tools needtickets:read; write tools needtickets:write. All go through the existing models/visibility checks rather than new SQL.6. Open questions / risks
explicitre-prompts on every sign-in;pre-configuredremembers consent for a period. Probably pre-configured.--callback-port(or we register a few ports).Suggested build order (when we move to implementation)
tinker-tickets-mcpclient. Test a token manually (curl + PKCE) and confirm the JWT has the rightaud,preferred_username,groups./mcp+/.well-known/oauth-protected-resourcefrom forward-auth./mcpendpoint with read-only tools first; then write tools.claude mcp add ... --client-id ... --callback-port ...→/mcpsign-in → call tools; verify visibility and attribution match the web UI for the same user.Sources
Decision & implementation plan
Decision: use the official MCP PHP SDK (
mcp/sdk), pinned and containedWhy the SDK over hand-rolling
initializehandshake. Anthropic says support for it is still rolling out across Claude products, so during the transition our server has to serve both the handshake era (sessions,Mcp-Session-Id) and the stateless era (Mcp-Method/Mcp-Nameheaders,_metaon every request). The SDK (v0.8+) serves both from one endpoint and classifies each request. Hand-rolling both would be most of the work and most of the bug surface.AuthorizationMiddleware(spec-correct401/403+WWW-Authenticatewithresource_metadata/scope),ProtectedResourceMetadataMiddleware(RFC 9728),JwtTokenValidator(JWKS signature +iss+aud+exp, scopes and claims exposed as attributes),OAuthRequestMetaMiddleware(passes the token's claims into the request context for tool handlers).The costs, and how we contain them
[BC Break]entries). → Pin the exact version ("mcp/sdk": "0.8.1", not^0.8), commitcomposer.lock, and upgrade deliberately after reading the CHANGELOG. All SDK usage lives in one directory (mcp/), so an upgrade can only break that directory.require vendor/autoload.php. If acomposer installever fails during deploy, only/mcpgoes down and the rest of Tinker Tickets is unaffected.vendor/is not committed (gitignored), so CI (phpcs/semgrep over.) never sees it. Production already hascomposerinstalled and can reach Packagist (verified).firebase/php-jwt(required byJwtTokenValidator),nyholm/psr7+nyholm/psr7-server(PSR-7 request from globals),laminas/laminas-httphandlerrunner(emit the response),symfony/http-client(PSR-18 client for OIDC discovery/JWKS),symfony/cache(PSR-16 cache so discovery + JWKS aren't re-fetched from Authelia on every request).Architecture
File layout (new):
composer.json+composer.lock(root),mcp/server.php(the only entrypoint),mcp/Tools/*.php(one class per tool),mcp/Auth/McpUserResolver.php(token claims → Tinker Tickets user),mcp/Auth/ToolScopeMiddleware.php(per-tool scope →403 insufficient_scope), plusmcp/sessions/for SDK sessions (handshake-era clients; gitignored, not web-readable).Identity & permissions: MCP runs as the signed-in user, with the same rules as the web UI
checkGroupAccess($groups), then runsUserModel::syncUserFromAuthelia(username, name, email, groups). MCP will do exactly the same from the token'spreferred_username,name,email,groupsclaims. The group-access check gets extracted fromAuthMiddlewareinto a shared helper so both paths enforce one rule instead of two copies.canUserAccessTicket,WorkflowModeltransitions/requires_comment, transactional status+comment,AuditLogModel, stats cache invalidation, notifications). No new SQL paths. Where endpoint logic is currently inline (e.g.api/ticket_status_api.php), it gets extracted into a small shared service that both the endpoint and the MCP tool call.Security requirements (non-negotiable)
Remote-User/Remote-*headers or$_SESSIONfor identity. The token is the only credential. Found during research: NPM's auth-exempt locations (e.g./api/tickets_api.php) forward client-supplied headers untouched, and they arrive from NPM'sTRUSTED_PROXIESIP. Harmless today, because none of those endpoints readRemote-User(verified), but/mcpmust never rely on that header either./mcplocation also blanksRemote-User/Remote-Groups/Remote-Name/Remote-Emailbefore proxying.vendor/,composer.json/composer.lockandmcp/sessions/are denied in the app's nginx config, since the webroot is the repo root.aud= our canonical URL, verified on every request, plusiss, signature,exp. Tokens are never passed through to anything else.tickets:readfor read tools,tickets:writefor write tools, enforced before dispatch byToolScopeMiddlewarewith a spec-correct403+WWW-Authenticate: ... error="insufficient_scope".Tools (v1)
search_tickets(status/priority/category/assignee/text, paginated)TicketModel::getAllTickets()with the user's visibility filterget_ticket(+ comments)getTicketById+canUserAccessTicket+CommentModelcreate_ticketTicketModel::createTicketpath used by the web UIadd_commentCommentModel::addCommentupdate_status(comment required when the workflow says so)assign_ticketassignTicket+ audit logConfiguration decisions (made; revisit if you disagree)
https://t.lotusguild.org/mcp, betahttps://beta.t.lotusguild.org/mcp. Both listed in the Authelia client'saudience. Each server only accepts its own.tinker-tickets-mcp: public client + PKCE S256,token_endpoint_auth_method: none, JWT access tokens (RS256), redirecthttp://localhost:47823/callback(fixed callback port 47823; everyone uses--callback-port 47823),consent_mode: pre-configured(remembered for 1 week, not re-prompted on every sign-in), custom lifespans: access token 1h, refresh token 30d.tickets:read/tickets:writein Authelia'sscopeswith claimspreferred_username, name, email, groups, plus aclaims_policythat copies them into the access token. Then requesting the ticket scopes alone yields a token the resource server can map to a user.resourcebug that breaks MCP sign-in); ≥4.39.23 is fine./.well-known/oauth-protected-resource/mcp(RFC 9728 path form) and/.well-known/oauth-protected-resource. Both are exempt from forward-auth.Phases (each ends with its own verification; beta before prod)
aud,scope,preferred_username,groupslook right, and withresource=present the exchange succeeds (noinvalid_target).development→ beta):composer.json/lock,.gitignore,.phpcs.xmlexclude,mcp/server.phpwith auth + metadata middleware and no tools yet. App nginx:/mcprewrite + deny rules (pve-infra repo, auto-deploys). Beta deploy script getscomposer install --no-dev --classmap-authoritative. Verify: unauthenticatedPOST /mcp→401with correctWWW-Authenticate; metadata JSON correct;vendor/andcomposer.jsonreturn403/404./mcp+ well-known paths, blankRemote-*. Verify: same checks through the public hostname, plus a forgedRemote-Userheader has no effect.McpUserResolver, shared group-access helper,search_tickets,get_ticket. Verify:claude mcp add --transport http --client-id tinker-tickets-mcp --callback-port 47823 tinker-beta https://beta.t.lotusguild.org/mcp→/mcpsign-in → tools listed; results match the web UI for the same user (a confidential ticket they can't see stays invisible); a token for the prod audience is rejected by beta; expired or tampered tokens are rejected.create_ticket,add_comment,update_status,assign_ticket,ToolScopeMiddleware. Verify: actions appear in the ticket timeline and audit log as the user; workflow rules and required comments are enforced; a read-only token gets403 insufficient_scopeon write tools.main, add the Composer step to the prod deploy script, NPM host 14 exemptions, then the same end-to-end checks againsthttps://t.lotusguild.org/mcp. README section on connecting Claude Code.Rollback
Everything is additive. Removing the NPM exemption (or the nginx rewrite) takes
/mcpoffline instantly without touching the rest of the app. The Authelia client can be deleted independently. No database schema changes are needed.Needs your hands (or explicit OK) at implementation time
configuration.ymlis managed by hand (lots of secrets). I'll back it up, show you the diff, and restart only with your OK./usr/local/bin/tinker_deploy.sh,tinker_beta_deploy.sh) aren't in any repo. I'll add the Composer step and suggest tracking them in pve-infra.Phase 1 done ✅: Authelia client
tinker-tickets-mcplive and verifiedApplied to
/etc/authelia/configuration.ymlon CT 167 (additive only; backupconfiguration.yml.bak.20260924183209; validated withauthelia config validatebefore swapping in; restarted cleanly at 18:32, which signed everyone out once since sessions are in-memory):lifespans.custom.tinker_mcp: access 1h, refresh 30dclaims_policies.tinker_mcp:preferred_username,name,email,groupscopied into the access tokentickets:read/tickets:writeauthorization_policies.tinker_tickets: one_factor,group:admin/group:employee(same as thet.lotusguild.orgaccess_control rule and the app'scheckGroupAccess)tinker-tickets-mcp: PKCE S256,token_endpoint_auth_method: none, redirecthttp://localhost:47823/callback, audience = prod + beta/mcpURLs, RS256 JWT access tokens,pre-configuredconsent (1w)Verified with a real browser sign-in + manual PKCE exchange, sending exactly what Claude Code sends (
resource=https://beta.t.lotusguild.org/mcp):stateand RFC 9207issin the authorization response ✅resourcesucceeds (noinvalid_targeton 4.39.20) ✅at+jwt; signature verifies against the public JWKS (kid 693140-rs256) ✅aud=["https://beta.t.lotusguild.org/mcp"]only, so it's bound to the requested resource, not every audience in the whitelist ✅preferred_username,name,email,groups✅.subis an opaque UUID, as expected, so mapping goes bypreferred_username.offline_access); a refresh grant keepsaud, scopes and identity, and rotates the refresh token ✅Findings that change the implementation
scp(a JSON array), not the standardscopestring. The SDK'sJwtTokenValidatordefaults toscopeClaim: 'scope', so it must be constructed withscopeClaim: 'scp', or every scope check fails closed.offline_access. Per the MCP spec the server SHOULD NOT putoffline_accessin its challenge, so if Claude Code doesn't add it on its own, users would re-authenticate hourly. Fallback: document anoauth.scopesoverride ("tickets:read tickets:write offline_access") in the Claude Code setup instructions. To be checked in phase 4.Phases 2–4 done ✅: read-only MCP live on beta, verified with a real Claude Code sign-in
Phase 2: scaffolding (
5631731,13660b4; pve-infra46fdf50)composer.json/composer.lock(MCP only;mcp/sdkpinned to exactly0.8.1; resolved for PHP 8.2 so it installs on 8.2 and 8.4),vendor/gitignored and excluded from phpcs.mcp/server.php: SDK Streamable HTTP +AuthorizationMiddleware(JWKS/iss/aud/exp;scopeClaim: 'scp') + RFC 9728 metadata at/.well-known/oauth-protected-resource/mcpand the root form. OIDC discovery + JWKS are cached (PSR-16, outside the webroot).composer install --no-dev ... --classmap-authoritative. A failure only affects/mcp(backup kept attinker_beta_deploy.sh.bak.*)./mcp+ well-known →mcp/server.php.vendor/,mcp/,composer.json/.lock→ 404 (^~so they beat the\.php$regex).resource_metadataURL from the request URI. Behind TLS-terminating NPM that washttp://plus a client-controlled Host. Fixed by pinning scheme/host toMCP_RESOURCE_URL.ServerRequestCreatorduplicatesHostunder PHP-FPM ("h, h"), so the SDK's DNS-rebinding check refused every request with 403. It only passed locally by luck. Fixed by collapsing Host to the client's single value; foreign hosts and direct-by-IP access are still refused.git fetch(transient, apparently fired before the push landed). A re-run applied it;nginx -tpassed.Phase 3: NPM (beta host 42):
/mcp+/.well-known/oauth-protected-resourceexempt from forward-auth,Remote-*headers blanked. Verified via the public hostname:401OAuth challenge (not an Authelia 302), metadata public, forgedRemote-Userignored, rest of beta still behind Authelia, Bearer API unchanged.Phase 4: identity + read tools (
65deedc)IdentityMiddleware: token → user via the same rules as the web login. The admin/employee check was extracted tohelpers/AccessPolicy.phpand verified identical to the old inline code on 15 inputs, including injection/casing/empty cases. ThensyncUserFromAuthelia().OAuthRequestMetaMiddlewareis not used. Itarray_merges claims into client-writable JSON-RPC_meta, so only the keys the validator sets get overwritten and a client could inject others. Claims are read from the validated token's server-side PSR-7 attributes instead.ToolScopeMiddleware: lifecycle needs only a valid token; write tools (single registry:ToolCatalog) needtickets:write; everything else needstickets:read(write implies read). Spec 403insufficient_scopechallenge.search_tickets(text/status/priority/category/assigneeme/unassigned/username, paginated, non-Closed by default) andget_ticket(details + comments; missing and not-visible both return "not found"), bothreadOnlyHint.preferred_username, scope 403s), plus the stateless 2026-07-28 era.Real end-to-end on beta (Claude Code 2.1.282):
claude mcp add --transport http --scope user --client-id tinker-tickets-mcp --callback-port 47823 tinker-beta https://beta.t.lotusguild.org/mcp→ Claude Code auto-discovered the OAuth challenge ("Needs authentication").claude mcp login tinker-beta --no-browser→ Authelia sign-in → authenticated. Claude Code requestedtickets:read tickets:write offline_accesson its own (open question 2 resolved: refresh tokens are issued, no scope override needed), sentresourceand PKCE, and validatediss.claude -psession calledsearch_tickets→ 32 non-Closed tickets, matching the beta DB exactly for an admin user, andget_ticket→ full details, 2 comments, correct beta URL. The user row was synced (is_admin=1from token groups). No protocol sessions were created, so Claude Code appears to have used the stateless 2026-07-28 era.Next: phase 5: write tools (
create_ticket,add_comment,update_status,assign_ticket) + tests, then phase 6 (production).Phase 5 done ✅: write tools live on beta, verified end-to-end
Refactors so MCP and the web UI share one code path (each its own commit, each verified over real HTTP with a real session + CSRF against the web endpoints):
6ce3380:ApiTicketControllermoved verbatim fromapi/update_ticket.phptocontrollers/ApiTicketController.php(class body byte-identical to the original).d5832fb:services/CommentService.phpextracted fromapi/add_comment.php(diffs against the original only where "emit error + exit" became "return [..., http_status]").9e462f7:services/AssignmentService.phpextracted fromapi/assign_ticket.php. One deliberate difference: its early error responses now also carry the rotated CSRF token viaapiRespond()(compatible; avoids a stale token after a failed assign).f206bb5:services/TicketCreationService.phpextracted fromTicketController::create. The web form still redirects on success and re-renders with the same error messages.Tools (
17be55b):create_ticket,add_comment,update_status,assign_ticket, registered inToolCatalog::WRITE_TOOLSso they needtickets:write. Dropdown-constrained web inputs (priority, visibility, status) are validated in the tools; an invisible ticket reads as "not found".Local: 30/30 write checks through the real pipeline + MariaDB with seeded workflow transitions (scope 403 with nothing written, attribution + audit per user, @mentions, internal-visibility groups, requires_comment, close-with-reason in one transaction, transitions outside the workflow refused, admin/creator/assignee assign rule). The 20 read checks still pass after the refactors.
Real end-to-end on beta (a fresh
claude -psession, existing sign-in): ⚠️ beta shares production's database (ticketing_systemon 10.10.10.50), so with the owner's OK this ran as one labeled test ticket, #194194263 "[MCP test] End-to-end write test - safe to delete":create (P5 Task) → comment → assign to me →
Open → Closedwith reason → get_ticket. All steps OK. Server side: creator/assigneejared,closed_atset, audit trailcreate → comment → assign → update {Open→Closed}all asjared. The ticket can be deleted from the web UI.Next: phase 6 (production). Suggested gate first: a quick human smoke test of the beta web UI (create a ticket, comment, assign, change status), since phase 5 refactored those four web endpoints and merging to
mainships them to prod.Phase 6 done ✅: MCP server live in production
Shipped (
9bbe4efmerge tomain, docsdad066c):/usr/local/bin/tinker_deploy.sh) runscomposer install --no-dev ... --classmap-authoritativeafter the.envrestore (backup kept). A failure only affects/mcp.cc038c6):/mcp+ well-known →mcp/server.php;vendor/,mcp/,composer.*→ 404./mcp+/.well-known/oauth-protected-resourceexempt from forward-auth,Remote-*blanked.Verified on production:
401challenge withresource_metadata=https://t.lotusguild.org/.well-known/oauth-protected-resource/mcp; metadataresource=https://t.lotusguild.org/mcp; bad or forged credentials refused; web UI still behind Authelia; Bearer API unchanged; internals unreachable.claude mcp login tinker --no-browser), then a fresh session:search_tickets→ 32, matching the prod DB's non-Closed count exactly;get_ticketon test ticket #194194263 → correct details and prod URL.Also fixed along the way (separate commit, pve-infra
bad2f5a): prod nginx served/uploads/directly (verified:/uploads/avatars/user_2.jpg→ 200 with the file), bypassingdownload_attachment.php's ticket-visibility check for attachments. It now haslocation ^~ /uploads/ { internal; }(→ 404), and beta's existing rule was tightened to the same^~form. Avatars and attachments are streamed by PHP, so they're unaffected.How to connect
Follow-ups (not blocking)
mcp/sdkis pinned to 0.8.1 and makes BC breaks in most minor releases. Upgrade deliberately after reading its CHANGELOG; all SDK usage is confined tomcp/.git fetchfailure on the first beta push) and slow once (~20s). Worth a look if it recurs.migrationstable records an older file set; 005–007 were applied by hand, and 004 (ticket_watchers.ticket_idint → varchar + FK) is still not applied in prod. It predates this work; deciding when to run it is a separate call.