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
Owner

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

  • No shared standing credential. Tokens are short-lived, tied to one person, and refreshed automatically by the client.
  • Real identity. API keys get blanket or public-only visibility and are attributed to the key's name. OAuth tokens map to an actual user, so their existing visibility rules and groups apply unchanged.
  • Easy revocation. Signing out or disabling the user in LLDAP/Authelia cuts off access, with no key to hunt down.

Rough shape (to be confirmed by research)

  • Tinker Tickets serves an MCP endpoint (Streamable HTTP), e.g. https://t.lotusguild.org/mcp, acting as the OAuth resource server.
  • Authelia (already our SSO, already has OIDC enabled) acts as the authorization server.
  • Claude Code connects with claude mcp add --transport http ... and signs in through the browser via /mcp.
  • Candidate tools: list/search tickets, read a ticket with its comments, create a ticket, comment, change status, assign.

Out of scope for now

  • Replacing the existing Bearer API or API keys; automation such as hwmonDaemon and gandalf keeps using those.
  • Writing code. This issue is in the research/design phase first.

Research findings will be posted as comments below.

## 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 - **No shared standing credential.** Tokens are short-lived, tied to one person, and refreshed automatically by the client. - **Real identity.** API keys get blanket or public-only visibility and are attributed to the key's name. OAuth tokens map to an actual user, so their existing visibility rules and groups apply unchanged. - **Easy revocation.** Signing out or disabling the user in LLDAP/Authelia cuts off access, with no key to hunt down. ## Rough shape (to be confirmed by research) - Tinker Tickets serves an MCP endpoint (Streamable HTTP), e.g. `https://t.lotusguild.org/mcp`, acting as the OAuth **resource server**. - **Authelia** (already our SSO, already has OIDC enabled) acts as the **authorization server**. - Claude Code connects with `claude mcp add --transport http ...` and signs in through the browser via `/mcp`. - Candidate tools: list/search tickets, read a ticket with its comments, create a ticket, comment, change status, assign. ## Out of scope for now - Replacing the existing Bearer API or API keys; automation such as hwmonDaemon and gandalf keeps using those. - Writing code. This issue is in the research/design phase first. Research findings will be posted as comments below.
Author
Owner

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 /mcp endpoint. 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)

  • MCP server (us): MUST serve RFC 9728 Protected Resource Metadata (/.well-known/oauth-protected-resource) naming Authelia as its authorization server. MUST answer unauthenticated requests with 401 + WWW-Authenticate: Bearer resource_metadata="..." (SHOULD also include scope=). MUST validate that every token was issued for this server (audience check, RFC 8707) and reject anything else with 401. MUST NOT pass tokens through to other services. Insufficient scope → 403 + error="insufficient_scope".
  • Client registration: Client ID Metadata Documents are now the SHOULD. Dynamic Client Registration is deprecated (MAY). Pre-registration is explicitly allowed, and that's the path we'd take.
  • Clients always send the RFC 8707 resource parameter (= our canonical URL) on both the authorize and token requests, whether or not the authorization server supports it.

2. Claude Code support

  • Supports remote HTTP MCP servers with OAuth, sign-in via /mcp or claude mcp login <name>, plus automatic token refresh (keychain/credentials file).
  • Supports pre-registered clients: 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 is http://localhost:<PORT>/callback (exact match, localhost not 127.0.0.1).
  • Also supports DCR and CIMD, but Authelia offers neither (see below).

3. Authelia as the authorization server

Needed Authelia status
OAuth 2.1 auth code + PKCE (S256), public clients ✅ supported
RFC 8414 metadata / OIDC discovery ✅
RFC 9207 iss in auth response ✅ (v4.38+)
RFC 9068 JWT access tokens (so we can validate locally) ✅ via access_token_signed_response_alg (v4.38+)
Custom claims in access token (preferred_username, groups) ✅ via claims_policies.<name>.access_token
Custom scopes (e.g. tickets:read, tickets:write) ✅ via the top-level scopes definitions
RFC 8707 resource parameter ⚠️ works on our v4.39.20. Broken in v4.39.21–v4.39.22 (resource validated but never granted → token exchange fails invalid_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.
Dynamic Client Registration ❌ not available (in progress for v4.40)
Client ID Metadata Documents ❌ not supported

Sketch of the Authelia client (for review, not applied):

identity_providers:
  oidc:
    claims_policies:
      tinker_mcp:
        access_token: ['preferred_username', 'groups']
    clients:
      - client_id: 'tinker-tickets-mcp'
        client_name: 'Tinker Tickets MCP (Claude Code)'
        public: true
        require_pkce: true
        pkce_challenge_method: 'S256'
        token_endpoint_auth_method: 'none'
        redirect_uris: ['http://localhost:<PORT>/callback']
        audience: ['https://t.lotusguild.org/mcp']
        grant_types: ['authorization_code', 'refresh_token']
        scopes: ['openid', 'profile', 'groups', 'offline_access', 'tickets:read', 'tickets:write']
        access_token_signed_response_alg: 'RS256'
        claims_policy: 'tinker_mcp'
        consent_mode: 'explicit'   # or pre-configured, to avoid re-prompting

4. Tinker Tickets side (the resource server)

  • Identity mapping: the web UI already trusts Authelia's Remote-User/Remote-Groups headers (middleware/AuthMiddleware.php). The MCP endpoint would do the same from the token's preferred_username + groups claims, reusing the same user sync and admin-group logic. That way MCP actions use the user's real visibility, admin status, and attribution. (Authelia's sub is expected to be an opaque ID rather than the username, hence the explicit preferred_username claim. To confirm during implementation.)
  • Token validation: verify the RS256 signature against Authelia's JWKS (cached), plus iss, aud == https://t.lotusguild.org/mcp, exp/nbf, and the required scope per tool. PHP's built-in openssl_verify is enough; no library strictly needed.
  • Reverse proxy: /mcp and /.well-known/oauth-protected-resource must 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.
  • Implementation choice:
    • Official PHP SDK (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 adding composer.json + vendor/ and a composer install step in tinker_deploy.sh (it currently just does git reset --hard).
    • Hand-rolled: a small stateless JSON-RPC endpoint (the 2026-07-28 revision is stateless, which fits PHP's request model well) + a self-written JWKS/JWT check. No new dependencies, but we own protocol compliance (e.g. the new required Mcp-Method/Mcp-Name headers) and have to track spec changes ourselves.
    • Leaning towards the SDK for spec compliance, but it's a real decision because of the Composer/deploy change.

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 need tickets:read; write tools need tickets:write. All go through the existing models/visibility checks rather than new SQL.

6. Open questions / risks

  1. SDK + Composer vs hand-rolled (above).
  2. Consent UX: explicit re-prompts on every sign-in; pre-configured remembers consent for a period. Probably pre-configured.
  3. Fixed callback port: every user's Claude Code must use the same --callback-port (or we register a few ports).
  4. Scope granularity: is read/write enough, or do we want a separate admin-only scope for bulk actions (not planned for v1)?
  5. Authelia upgrade policy: pin to ≥4.39.23 or stay on 4.39.20 until this ships (see RFC 8707 bug).
  6. Protocol version: confirm the Claude Code version in use negotiates cleanly with whichever protocol era the server speaks (the SDK handles both).

Suggested build order (when we move to implementation)

  1. Authelia: add scopes, claims policy, and the tinker-tickets-mcp client. Test a token manually (curl + PKCE) and confirm the JWT has the right aud, preferred_username, groups.
  2. Tinker Tickets: protected-resource metadata endpoint + 401 challenge + JWT validation middleware.
  3. NPM: exempt /mcp + /.well-known/oauth-protected-resource from forward-auth.
  4. /mcp endpoint with read-only tools first; then write tools.
  5. End-to-end: claude mcp add ... --client-id ... --callback-port ... → /mcp sign-in → call tools; verify visibility and attribution match the web UI for the same user.

Sources

## 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 `/mcp` endpoint. 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) - **MCP server (us):** MUST serve RFC 9728 Protected Resource Metadata (`/.well-known/oauth-protected-resource`) naming Authelia as its authorization server. MUST answer unauthenticated requests with `401` + `WWW-Authenticate: Bearer resource_metadata="..."` (SHOULD also include `scope=`). MUST validate that every token was issued **for this server** (audience check, RFC 8707) and reject anything else with `401`. MUST NOT pass tokens through to other services. Insufficient scope → `403` + `error="insufficient_scope"`. - **Client registration:** Client ID Metadata Documents are now the SHOULD. Dynamic Client Registration is deprecated (MAY). **Pre-registration is explicitly allowed**, and that's the path we'd take. - **Clients** always send the RFC 8707 `resource` parameter (= our canonical URL) on both the authorize and token requests, whether or not the authorization server supports it. ### 2. Claude Code support - Supports remote HTTP MCP servers with OAuth, sign-in via `/mcp` or `claude mcp login <name>`, plus automatic token refresh (keychain/credentials file). - Supports **pre-registered clients**: `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 is `http://localhost:<PORT>/callback` (exact match, `localhost` not `127.0.0.1`). - Also supports DCR and CIMD, but Authelia offers neither (see below). ### 3. Authelia as the authorization server | Needed | Authelia status | |---|---| | OAuth 2.1 auth code + PKCE (S256), public clients | ✅ supported | | RFC 8414 metadata / OIDC discovery | ✅ | | RFC 9207 `iss` in auth response | ✅ (v4.38+) | | RFC 9068 **JWT access tokens** (so we can validate locally) | ✅ via `access_token_signed_response_alg` (v4.38+) | | Custom claims in access token (`preferred_username`, `groups`) | ✅ via `claims_policies.<name>.access_token` | | Custom scopes (e.g. `tickets:read`, `tickets:write`) | ✅ via the top-level `scopes` definitions | | RFC 8707 `resource` parameter | ⚠️ **works on our v4.39.20.** Broken in **v4.39.21–v4.39.22** (resource validated but never granted → token exchange fails `invalid_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.** | | Dynamic Client Registration | ❌ not available (in progress for v4.40) | | Client ID Metadata Documents | ❌ not supported | Sketch of the Authelia client (for review, not applied): ```yaml identity_providers: oidc: claims_policies: tinker_mcp: access_token: ['preferred_username', 'groups'] clients: - client_id: 'tinker-tickets-mcp' client_name: 'Tinker Tickets MCP (Claude Code)' public: true require_pkce: true pkce_challenge_method: 'S256' token_endpoint_auth_method: 'none' redirect_uris: ['http://localhost:<PORT>/callback'] audience: ['https://t.lotusguild.org/mcp'] grant_types: ['authorization_code', 'refresh_token'] scopes: ['openid', 'profile', 'groups', 'offline_access', 'tickets:read', 'tickets:write'] access_token_signed_response_alg: 'RS256' claims_policy: 'tinker_mcp' consent_mode: 'explicit' # or pre-configured, to avoid re-prompting ``` ### 4. Tinker Tickets side (the resource server) - **Identity mapping:** the web UI already trusts Authelia's `Remote-User`/`Remote-Groups` headers (`middleware/AuthMiddleware.php`). The MCP endpoint would do the same from the token's `preferred_username` + `groups` claims, reusing the same user sync and admin-group logic. That way MCP actions use the user's real visibility, admin status, and attribution. (Authelia's `sub` is expected to be an opaque ID rather than the username, hence the explicit `preferred_username` claim. To confirm during implementation.) - **Token validation:** verify the RS256 signature against Authelia's JWKS (cached), plus `iss`, `aud == https://t.lotusguild.org/mcp`, `exp`/`nbf`, and the required scope per tool. PHP's built-in `openssl_verify` is enough; no library strictly needed. - **Reverse proxy:** `/mcp` and `/.well-known/oauth-protected-resource` must 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. - **Implementation choice:** - *Official PHP SDK* (`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 adding `composer.json` + `vendor/` and a `composer install` step in `tinker_deploy.sh` (it currently just does `git reset --hard`). - *Hand-rolled:* a small stateless JSON-RPC endpoint (the 2026-07-28 revision is stateless, which fits PHP's request model well) + a self-written JWKS/JWT check. No new dependencies, but we own protocol compliance (e.g. the new required `Mcp-Method`/`Mcp-Name` headers) and have to track spec changes ourselves. - Leaning towards the SDK for spec compliance, but it's a real decision because of the Composer/deploy change. ### 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 need `tickets:read`; write tools need `tickets:write`. All go through the existing models/visibility checks rather than new SQL. ### 6. Open questions / risks 1. **SDK + Composer vs hand-rolled** (above). 2. **Consent UX:** `explicit` re-prompts on every sign-in; `pre-configured` remembers consent for a period. Probably pre-configured. 3. **Fixed callback port:** every user's Claude Code must use the same `--callback-port` (or we register a few ports). 4. **Scope granularity:** is read/write enough, or do we want a separate admin-only scope for bulk actions (not planned for v1)? 5. **Authelia upgrade policy:** pin to ≥4.39.23 or stay on 4.39.20 until this ships (see RFC 8707 bug). 6. **Protocol version:** confirm the Claude Code version in use negotiates cleanly with whichever protocol era the server speaks (the SDK handles both). ### Suggested build order (when we move to implementation) 1. Authelia: add scopes, claims policy, and the `tinker-tickets-mcp` client. Test a token manually (curl + PKCE) and confirm the JWT has the right `aud`, `preferred_username`, `groups`. 2. Tinker Tickets: protected-resource metadata endpoint + 401 challenge + JWT validation middleware. 3. NPM: exempt `/mcp` + `/.well-known/oauth-protected-resource` from forward-auth. 4. `/mcp` endpoint with read-only tools first; then write tools. 5. End-to-end: `claude mcp add ... --client-id ... --callback-port ...` → `/mcp` sign-in → call tools; verify visibility and attribution match the web UI for the same user. ### Sources - MCP authorization spec (2026-07-28): https://modelcontextprotocol.io/specification/latest/basic/authorization - Claude Code MCP docs (OAuth, pre-registered clients): https://code.claude.com/docs/en/mcp - Authelia OIDC roadmap/features: https://www.authelia.com/roadmap/active/openid-connect-1.0-provider/ - Authelia client config: https://www.authelia.com/configuration/identity-providers/openid-connect/clients/ - Authelia provider config (claims_policies, scopes): https://www.authelia.com/configuration/identity-providers/openid-connect/provider/ - Authelia RFC 8707 regression: https://github.com/authelia/authelia/issues/12970, https://github.com/authelia/authelia/issues/13113, fix https://github.com/authelia/authelia/pull/12973 (v4.39.23) - Authelia DCR status: https://github.com/authelia/authelia/discussions/7304 - Official MCP PHP SDK: https://github.com/modelcontextprotocol/php-sdk
Author
Owner

Decision & implementation plan

Decision: use the official MCP PHP SDK (mcp/sdk), pinned and contained

Why the SDK over hand-rolling

  • Two protocol eras at once. MCP 2026-07-28 removed sessions and the initialize handshake. 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-Name headers, _meta on 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.
  • OAuth resource-server pieces are already built and match the spec: AuthorizationMiddleware (spec-correct 401/403 + WWW-Authenticate with resource_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).
  • It has a Keycloak OAuth example very close to our Authelia setup, and it's maintained with the PHP Foundation.

The costs, and how we contain them

  • Pre-1.0 (v0.8.1), with breaking changes in almost every minor release (0.6, 0.7, 0.8 and the upcoming 0.9 all have [BC Break] entries). → Pin the exact version ("mcp/sdk": "0.8.1", not ^0.8), commit composer.lock, and upgrade deliberately after reading the CHANGELOG. All SDK usage lives in one directory (mcp/), so an upgrade can only break that directory.
  • The project has no Composer today. → Composer is only for the MCP endpoint. Nothing else in the app may require vendor/autoload.php. If a composer install ever fails during deploy, only /mcp goes down and the rest of Tinker Tickets is unaffected. vendor/ is not committed (gitignored), so CI (phpcs/semgrep over .) never sees it. Production already has composer installed and can reach Packagist (verified).
  • Runtime deps it pulls in (≈15 PSR/Symfony packages), plus what we add for PHP-FPM: firebase/php-jwt (required by JwtTokenValidator), 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

Claude Code ──(browser sign-in, PKCE)──▶ Authelia (auth.lotusguild.org)  ← authorization server, issues JWT access tokens
     │                                          ▲
     │ Bearer <JWT>                             │ OIDC discovery + JWKS (cached)
     ▼                                          │
NPM (t.lotusguild.org) ── /mcp exempt from forward-auth ──▶ tinker nginx ──▶ mcp/server.php (SDK)
                                                                                 │ token → preferred_username/groups
                                                                                 ▼
                                                                  existing models (TicketModel, CommentModel, WorkflowModel, AuditLogModel…)

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), plus mcp/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

  • The web login today checks checkGroupAccess($groups), then runs UserModel::syncUserFromAuthelia(username, name, email, groups). MCP will do exactly the same from the token's preferred_username, name, email, groups claims. The group-access check gets extracted from AuthMiddleware into a shared helper so both paths enforce one rule instead of two copies.
  • Every tool goes through the existing models and checks (canUserAccessTicket, WorkflowModel transitions/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)

  1. The MCP entrypoint never reads Remote-User/Remote-* headers or $_SESSION for 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's TRUSTED_PROXIES IP. Harmless today, because none of those endpoints read Remote-User (verified), but /mcp must never rely on that header either.
  2. Defense in depth in NPM: the /mcp location also blanks Remote-User/Remote-Groups/Remote-Name/Remote-Email before proxying.
  3. vendor/, composer.json/composer.lock and mcp/sessions/ are denied in the app's nginx config, since the webroot is the repo root.
  4. Tokens must carry aud = our canonical URL, verified on every request, plus iss, signature, exp. Tokens are never passed through to anything else.
  5. Per-tool scopes: tickets:read for read tools, tickets:write for write tools, enforced before dispatch by ToolScopeMiddleware with a spec-correct 403 + WWW-Authenticate: ... error="insufficient_scope".

Tools (v1)

Tool Scope Backed by
search_tickets (status/priority/category/assignee/text, paginated) read TicketModel::getAllTickets() with the user's visibility filter
get_ticket (+ comments) read getTicketById + canUserAccessTicket + CommentModel
create_ticket write TicketModel::createTicket path used by the web UI
add_comment write CommentModel::addComment
update_status (comment required when the workflow says so) write shared status service (transactional, workflow-validated)
assign_ticket write assignTicket + audit log

Configuration decisions (made; revisit if you disagree)

  • Canonical URLs: prod https://t.lotusguild.org/mcp, beta https://beta.t.lotusguild.org/mcp. Both listed in the Authelia client's audience. Each server only accepts its own.
  • Authelia client tinker-tickets-mcp: public client + PKCE S256, token_endpoint_auth_method: none, JWT access tokens (RS256), redirect http://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.
  • Custom scopes carry the identity claims: define tickets:read/tickets:write in Authelia's scopes with claims preferred_username, name, email, groups, plus a claims_policy that copies them into the access token. Then requesting the ticket scopes alone yields a token the resource server can map to a user.
  • Authelia version: stay on v4.39.20 until we upgrade deliberately. Never 4.39.21/4.39.22 (RFC 8707 resource bug that breaks MCP sign-in); ≥4.39.23 is fine.
  • Well-known paths: serve Protected Resource Metadata at both /.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)

  1. Authelia (CT 167): add scopes, claims policy, lifespans and the client (config backed up first; restart). Verify: a manual PKCE auth-code flow with curl returns a JWT whose aud, scope, preferred_username, groups look right, and with resource= present the exchange succeeds (no invalid_target).
  2. App scaffolding (on development → beta): composer.json/lock, .gitignore, .phpcs.xml exclude, mcp/server.php with auth + metadata middleware and no tools yet. App nginx: /mcp rewrite + deny rules (pve-infra repo, auto-deploys). Beta deploy script gets composer install --no-dev --classmap-authoritative. Verify: unauthenticated POST /mcp → 401 with correct WWW-Authenticate; metadata JSON correct; vendor/ and composer.json return 403/404.
  3. NPM (beta host 42): exempt /mcp + well-known paths, blank Remote-*. Verify: same checks through the public hostname, plus a forged Remote-User header has no effect.
  4. Identity + read tools: 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 → /mcp sign-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.
  5. Write tools + scope enforcement: 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 gets 403 insufficient_scope on write tools.
  6. Production: merge to main, add the Composer step to the prod deploy script, NPM host 14 exemptions, then the same end-to-end checks against https://t.lotusguild.org/mcp. README section on connecting Claude Code.

Rollback

Everything is additive. Removing the NPM exemption (or the nginx rewrite) takes /mcp offline 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

  • NPM proxy-host edits (hosts 14 and 42) are in NPM's SQLite DB and are UI-only. I'll provide the exact Advanced-tab snippet, or I can make the edit through the UI/API if you prefer.
  • Authelia configuration.yml is managed by hand (lots of secrets). I'll back it up, show you the diff, and restart only with your OK.
  • Deploy scripts (/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.
## Decision & implementation plan ### Decision: use the official MCP PHP SDK (`mcp/sdk`), pinned and contained **Why the SDK over hand-rolling** - **Two protocol eras at once.** MCP 2026-07-28 removed sessions and the `initialize` handshake. 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-Name` headers, `_meta` on 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. - **OAuth resource-server pieces are already built and match the spec:** `AuthorizationMiddleware` (spec-correct `401`/`403` + `WWW-Authenticate` with `resource_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). - It has a Keycloak OAuth example very close to our Authelia setup, and it's maintained with the PHP Foundation. **The costs, and how we contain them** - **Pre-1.0 (v0.8.1), with breaking changes in almost every minor release** (0.6, 0.7, 0.8 and the upcoming 0.9 all have `[BC Break]` entries). → Pin the **exact** version (`"mcp/sdk": "0.8.1"`, not `^0.8`), commit `composer.lock`, and upgrade deliberately after reading the CHANGELOG. **All SDK usage lives in one directory (`mcp/`)**, so an upgrade can only break that directory. - **The project has no Composer today.** → Composer is **only** for the MCP endpoint. Nothing else in the app may `require vendor/autoload.php`. If a `composer install` ever fails during deploy, only `/mcp` goes down and the rest of Tinker Tickets is unaffected. `vendor/` is **not committed** (gitignored), so CI (phpcs/semgrep over `.`) never sees it. Production already has `composer` installed and can reach Packagist (verified). - **Runtime deps it pulls in** (≈15 PSR/Symfony packages), plus what we add for PHP-FPM: `firebase/php-jwt` (required by `JwtTokenValidator`), `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 ``` Claude Code ──(browser sign-in, PKCE)──▶ Authelia (auth.lotusguild.org) ← authorization server, issues JWT access tokens │ ▲ │ Bearer <JWT> │ OIDC discovery + JWKS (cached) ▼ │ NPM (t.lotusguild.org) ── /mcp exempt from forward-auth ──▶ tinker nginx ──▶ mcp/server.php (SDK) │ token → preferred_username/groups ▼ existing models (TicketModel, CommentModel, WorkflowModel, AuditLogModel…) ``` **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`), plus `mcp/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 - The web login today checks `checkGroupAccess($groups)`, then runs `UserModel::syncUserFromAuthelia(username, name, email, groups)`. **MCP will do exactly the same** from the token's `preferred_username`, `name`, `email`, `groups` claims. The group-access check gets extracted from `AuthMiddleware` into a shared helper so both paths enforce one rule instead of two copies. - Every tool goes through the **existing** models and checks (`canUserAccessTicket`, `WorkflowModel` transitions/`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) 1. **The MCP entrypoint never reads `Remote-User`/`Remote-*` headers or `$_SESSION` for 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's `TRUSTED_PROXIES` IP. Harmless today, because none of those endpoints read `Remote-User` (verified), but `/mcp` must never rely on that header either. 2. **Defense in depth in NPM:** the `/mcp` location also **blanks** `Remote-User`/`Remote-Groups`/`Remote-Name`/`Remote-Email` before proxying. 3. **`vendor/`, `composer.json`/`composer.lock` and `mcp/sessions/` are denied** in the app's nginx config, since the webroot is the repo root. 4. Tokens must carry `aud` = our canonical URL, verified on **every** request, plus `iss`, signature, `exp`. Tokens are never passed through to anything else. 5. **Per-tool scopes:** `tickets:read` for read tools, `tickets:write` for write tools, enforced **before** dispatch by `ToolScopeMiddleware` with a spec-correct `403` + `WWW-Authenticate: ... error="insufficient_scope"`. ### Tools (v1) | Tool | Scope | Backed by | |---|---|---| | `search_tickets` (status/priority/category/assignee/text, paginated) | read | `TicketModel::getAllTickets()` with the user's visibility filter | | `get_ticket` (+ comments) | read | `getTicketById` + `canUserAccessTicket` + `CommentModel` | | `create_ticket` | write | `TicketModel::createTicket` path used by the web UI | | `add_comment` | write | `CommentModel::addComment` | | `update_status` (comment required when the workflow says so) | write | shared status service (transactional, workflow-validated) | | `assign_ticket` | write | `assignTicket` + audit log | ### Configuration decisions (made; revisit if you disagree) - **Canonical URLs:** prod `https://t.lotusguild.org/mcp`, beta `https://beta.t.lotusguild.org/mcp`. Both listed in the Authelia client's `audience`. Each server only accepts its own. - **Authelia client** `tinker-tickets-mcp`: public client + PKCE S256, `token_endpoint_auth_method: none`, JWT access tokens (`RS256`), redirect `http://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. - **Custom scopes carry the identity claims:** define `tickets:read`/`tickets:write` in Authelia's `scopes` with claims `preferred_username, name, email, groups`, plus a `claims_policy` that copies them into the **access token**. Then requesting the ticket scopes alone yields a token the resource server can map to a user. - **Authelia version:** stay on v4.39.20 until we upgrade deliberately. **Never 4.39.21/4.39.22** (RFC 8707 `resource` bug that breaks MCP sign-in); ≥4.39.23 is fine. - **Well-known paths:** serve Protected Resource Metadata at both `/.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) 1. **Authelia (CT 167):** add scopes, claims policy, lifespans and the client (config backed up first; restart). *Verify:* a manual PKCE auth-code flow with curl returns a JWT whose `aud`, `scope`, `preferred_username`, `groups` look right, and with `resource=` present the exchange succeeds (no `invalid_target`). 2. **App scaffolding (on `development` → beta):** `composer.json`/lock, `.gitignore`, `.phpcs.xml` exclude, `mcp/server.php` with auth + metadata middleware and **no tools yet**. App nginx: `/mcp` rewrite + deny rules (pve-infra repo, auto-deploys). Beta deploy script gets `composer install --no-dev --classmap-authoritative`. *Verify:* unauthenticated `POST /mcp` → `401` with correct `WWW-Authenticate`; metadata JSON correct; `vendor/` and `composer.json` return `403`/`404`. 3. **NPM (beta host 42):** exempt `/mcp` + well-known paths, blank `Remote-*`. *Verify:* same checks through the public hostname, plus a forged `Remote-User` header has no effect. 4. **Identity + read tools:** `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` → `/mcp` sign-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. 5. **Write tools + scope enforcement:** `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 gets `403 insufficient_scope` on write tools. 6. **Production:** merge to `main`, add the Composer step to the prod deploy script, NPM host 14 exemptions, then the same end-to-end checks against `https://t.lotusguild.org/mcp`. README section on connecting Claude Code. ### Rollback Everything is additive. Removing the NPM exemption (or the nginx rewrite) takes `/mcp` offline 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 - **NPM proxy-host edits** (hosts 14 and 42) are in NPM's SQLite DB and are UI-only. I'll provide the exact Advanced-tab snippet, or I can make the edit through the UI/API if you prefer. - **Authelia `configuration.yml`** is managed by hand (lots of secrets). I'll back it up, show you the diff, and restart only with your OK. - **Deploy scripts** (`/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.
Author
Owner

Phase 1 done ✅: Authelia client tinker-tickets-mcp live and verified

Applied to /etc/authelia/configuration.yml on CT 167 (additive only; backup configuration.yml.bak.20260924183209; validated with authelia config validate before swapping in; restarted cleanly at 18:32, which signed everyone out once since sessions are in-memory):

  • lifespans.custom.tinker_mcp: access 1h, refresh 30d
  • claims_policies.tinker_mcp: preferred_username, name, email, groups copied into the access token
  • custom scopes tickets:read / tickets:write
  • authorization_policies.tinker_tickets: one_factor, group:admin / group:employee (same as the t.lotusguild.org access_control rule and the app's checkGroupAccess)
  • public client tinker-tickets-mcp: PKCE S256, token_endpoint_auth_method: none, redirect http://localhost:47823/callback, audience = prod + beta /mcp URLs, RS256 JWT access tokens, pre-configured consent (1w)

Verified with a real browser sign-in + manual PKCE exchange, sending exactly what Claude Code sends (resource=https://beta.t.lotusguild.org/mcp):

  • state and RFC 9207 iss in the authorization response ✅
  • Token exchange with resource succeeds (no invalid_target on 4.39.20) ✅
  • Access token is an RS256 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 ✅
  • Identity claims present: preferred_username, name, email, groups ✅. sub is an opaque UUID, as expected, so mapping goes by preferred_username.
  • Refresh token issued (needs offline_access); a refresh grant keeps aud, scopes and identity, and rotates the refresh token ✅

Findings that change the implementation

  1. Authelia puts scopes in scp (a JSON array), not the standard scope string. The SDK's JwtTokenValidator defaults to scopeClaim: 'scope', so it must be constructed with scopeClaim: 'scp', or every scope check fails closed.
  2. Refresh tokens require offline_access. Per the MCP spec the server SHOULD NOT put offline_access in its challenge, so if Claude Code doesn't add it on its own, users would re-authenticate hourly. Fallback: document an oauth.scopes override ("tickets:read tickets:write offline_access") in the Claude Code setup instructions. To be checked in phase 4.
## Phase 1 done ✅: Authelia client `tinker-tickets-mcp` live and verified **Applied** to `/etc/authelia/configuration.yml` on CT 167 (additive only; backup `configuration.yml.bak.20260924183209`; validated with `authelia config validate` before swapping in; restarted cleanly at 18:32, which signed everyone out once since sessions are in-memory): - `lifespans.custom.tinker_mcp`: access 1h, refresh 30d - `claims_policies.tinker_mcp`: `preferred_username`, `name`, `email`, `groups` copied into the **access token** - custom scopes `tickets:read` / `tickets:write` - `authorization_policies.tinker_tickets`: one_factor, `group:admin` / `group:employee` (same as the `t.lotusguild.org` access_control rule and the app's `checkGroupAccess`) - public client `tinker-tickets-mcp`: PKCE S256, `token_endpoint_auth_method: none`, redirect `http://localhost:47823/callback`, audience = prod + beta `/mcp` URLs, RS256 JWT access tokens, `pre-configured` consent (1w) **Verified with a real browser sign-in + manual PKCE exchange, sending exactly what Claude Code sends (`resource=https://beta.t.lotusguild.org/mcp`):** - `state` and RFC 9207 `iss` in the authorization response ✅ - Token exchange with `resource` succeeds (no `invalid_target` on 4.39.20) ✅ - Access token is an RS256 `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 ✅ - Identity claims present: `preferred_username`, `name`, `email`, `groups` ✅. `sub` is an opaque UUID, as expected, so mapping goes by `preferred_username`. - Refresh token issued (needs `offline_access`); a **refresh grant keeps `aud`, scopes and identity, and rotates the refresh token** ✅ **Findings that change the implementation** 1. **Authelia puts scopes in `scp` (a JSON array), not the standard `scope` string.** The SDK's `JwtTokenValidator` defaults to `scopeClaim: 'scope'`, so it must be constructed with `scopeClaim: 'scp'`, or every scope check fails closed. 2. **Refresh tokens require `offline_access`.** Per the MCP spec the server SHOULD NOT put `offline_access` in its challenge, so if Claude Code doesn't add it on its own, users would re-authenticate hourly. Fallback: document an `oauth.scopes` override (`"tickets:read tickets:write offline_access"`) in the Claude Code setup instructions. To be checked in phase 4.
Author
Owner

Phases 2–4 done ✅: read-only MCP live on beta, verified with a real Claude Code sign-in

Phase 2: scaffolding (5631731, 13660b4; pve-infra 46fdf50)

  • composer.json/composer.lock (MCP only; mcp/sdk pinned to exactly 0.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/mcp and the root form. OIDC discovery + JWKS are cached (PSR-16, outside the webroot).
  • Beta deploy script now runs composer install --no-dev ... --classmap-authoritative. A failure only affects /mcp (backup kept at tinker_beta_deploy.sh.bak.*).
  • Beta app nginx: /mcp + well-known → mcp/server.php. vendor/, mcp/, composer.json/.lock → 404 (^~ so they beat the \.php$ regex).
  • Bugs found and fixed while testing:
    1. The SDK builds the 401's resource_metadata URL from the request URI. Behind TLS-terminating NPM that was http:// plus a client-controlled Host. Fixed by pinning scheme/host to MCP_RESOURCE_URL.
    2. nyholm's ServerRequestCreator duplicates Host under 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.
  • Note: the pve-infra webhook deploy on CT 132 failed once on git fetch (transient, apparently fired before the push landed). A re-run applied it; nginx -t passed.

Phase 3: NPM (beta host 42): /mcp + /.well-known/oauth-protected-resource exempt from forward-auth, Remote-* headers blanked. Verified via the public hostname: 401 OAuth challenge (not an Authelia 302), metadata public, forged Remote-User ignored, 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 to helpers/AccessPolicy.php and verified identical to the old inline code on 15 inputs, including injection/casing/empty cases. Then syncUserFromAuthelia().
  • Design change from the plan: the SDK's OAuthRequestMetaMiddleware is not used. It array_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) need tickets:write; everything else needs tickets:read (write implies read). Spec 403 insufficient_scope challenge.
  • Tools: search_tickets (text/status/priority/category/assignee me/unassigned/username, paginated, non-Closed by default) and get_ticket (details + comments; missing and not-visible both return "not found"), both readOnlyHint.
  • Local: 20/20 checks against a real MariaDB fixture (per-user visibility parity, confidential/internal hidden correctly, group check, missing 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 requested tickets:read tickets:write offline_access on its own (open question 2 resolved: refresh tokens are issued, no scope override needed), sent resource and PKCE, and validated iss.
  • A fresh claude -p session called search_tickets → 32 non-Closed tickets, matching the beta DB exactly for an admin user, and get_ticket → full details, 2 comments, correct beta URL. The user row was synced (is_admin=1 from 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).

## Phases 2–4 done ✅: read-only MCP live on beta, verified with a real Claude Code sign-in **Phase 2: scaffolding** (`5631731`, `13660b4`; pve-infra `46fdf50`) - `composer.json`/`composer.lock` (MCP only; `mcp/sdk` pinned to exactly `0.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/mcp` and the root form. OIDC discovery + JWKS are cached (PSR-16, outside the webroot). - Beta deploy script now runs `composer install --no-dev ... --classmap-authoritative`. A failure only affects `/mcp` (backup kept at `tinker_beta_deploy.sh.bak.*`). - Beta app nginx: `/mcp` + well-known → `mcp/server.php`. `vendor/`, `mcp/`, `composer.json`/`.lock` → 404 (`^~` so they beat the `\.php$` regex). - **Bugs found and fixed while testing:** 1. The SDK builds the 401's `resource_metadata` URL from the request URI. Behind TLS-terminating NPM that was `http://` plus a client-controlled Host. Fixed by pinning scheme/host to `MCP_RESOURCE_URL`. 2. nyholm's `ServerRequestCreator` duplicates `Host` under 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. - Note: the pve-infra webhook deploy on CT 132 failed once on `git fetch` (transient, apparently fired before the push landed). A re-run applied it; `nginx -t` passed. **Phase 3: NPM (beta host 42)**: `/mcp` + `/.well-known/oauth-protected-resource` exempt from forward-auth, `Remote-*` headers blanked. Verified via the public hostname: `401` OAuth challenge (not an Authelia 302), metadata public, forged `Remote-User` ignored, 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 to `helpers/AccessPolicy.php` and verified identical to the old inline code on 15 inputs, including injection/casing/empty cases. Then `syncUserFromAuthelia()`. - **Design change from the plan:** the SDK's `OAuthRequestMetaMiddleware` is **not used**. It `array_merge`s 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`) need `tickets:write`; everything else needs `tickets:read` (write implies read). Spec 403 `insufficient_scope` challenge. - Tools: `search_tickets` (text/status/priority/category/assignee `me`/`unassigned`/username, paginated, non-Closed by default) and `get_ticket` (details + comments; missing and not-visible both return "not found"), both `readOnlyHint`. - Local: 20/20 checks against a real MariaDB fixture (per-user visibility parity, confidential/internal hidden correctly, group check, missing `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 requested `tickets:read tickets:write offline_access` on its own** (open question 2 resolved: refresh tokens are issued, no scope override needed), sent `resource` and PKCE, and validated `iss`. - A fresh `claude -p` session called `search_tickets` → **32 non-Closed tickets, matching the beta DB exactly** for an admin user, and `get_ticket` → full details, 2 comments, correct beta URL. The user row was synced (`is_admin=1` from 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).
Author
Owner

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: ApiTicketController moved verbatim from api/update_ticket.php to controllers/ApiTicketController.php (class body byte-identical to the original).
  • d5832fb: services/CommentService.php extracted from api/add_comment.php (diffs against the original only where "emit error + exit" became "return [..., http_status]").
  • 9e462f7: services/AssignmentService.php extracted from api/assign_ticket.php. One deliberate difference: its early error responses now also carry the rotated CSRF token via apiRespond() (compatible; avoids a stale token after a failed assign).
  • f206bb5: services/TicketCreationService.php extracted from TicketController::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 in ToolCatalog::WRITE_TOOLS so they need tickets: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 -p session, existing sign-in): ⚠️ beta shares production's database (ticketing_system on 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 → Closed with reason → get_ticket. All steps OK. Server side: creator/assignee jared, closed_at set, audit trail create → comment → assign → update {Open→Closed} all as jared. 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 main ships them to prod.

## 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`: `ApiTicketController` moved verbatim from `api/update_ticket.php` to `controllers/ApiTicketController.php` (class body byte-identical to the original). - `d5832fb`: `services/CommentService.php` extracted from `api/add_comment.php` (diffs against the original only where "emit error + exit" became "return [..., http_status]"). - `9e462f7`: `services/AssignmentService.php` extracted from `api/assign_ticket.php`. One deliberate difference: its early error responses now also carry the rotated CSRF token via `apiRespond()` (compatible; avoids a stale token after a failed assign). - `f206bb5`: `services/TicketCreationService.php` extracted from `TicketController::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 in `ToolCatalog::WRITE_TOOLS` so they need `tickets: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 -p` session, existing sign-in): ⚠️ **beta shares production's database** (`ticketing_system` on 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 → Closed` with reason → get_ticket. All steps OK. Server side: creator/assignee `jared`, `closed_at` set, audit trail `create → comment → assign → update {Open→Closed}` all as `jared`. 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 `main` ships them to prod.
Author
Owner

Phase 6 done ✅: MCP server live in production

Shipped (9bbe4ef merge to main, docs dad066c):

  • Prod deploy script (/usr/local/bin/tinker_deploy.sh) runs composer install --no-dev ... --classmap-authoritative after the .env restore (backup kept). A failure only affects /mcp.
  • Prod app nginx (pve-infra cc038c6): /mcp + well-known → mcp/server.php; vendor/, mcp/, composer.* → 404.
  • NPM prod host 14: /mcp + /.well-known/oauth-protected-resource exempt from forward-auth, Remote-* blanked.
  • README: "MCP server (Claude Code and other MCP clients)" section, plus the updated project tree.

Verified on production:

  • Public: 401 challenge with resource_metadata=https://t.lotusguild.org/.well-known/oauth-protected-resource/mcp; metadata resource = https://t.lotusguild.org/mcp; bad or forged credentials refused; web UI still behind Authelia; Bearer API unchanged; internals unreachable.
  • Real Claude Code sign-in (claude mcp login tinker --no-browser), then a fresh session: search_tickets → 32, matching the prod DB's non-Closed count exactly; get_ticket on 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), bypassing download_attachment.php's ticket-visibility check for attachments. It now has location ^~ /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

claude mcp add --transport http --scope user --client-id tinker-tickets-mcp --callback-port 47823 tinker https://t.lotusguild.org/mcp
claude mcp login tinker   # --no-browser on headless machines

Follow-ups (not blocking)

  1. Test ticket #194194263 ("[MCP test] …") can be deleted from the web UI.
  2. Authelia upgrades: skip 4.39.21/4.39.22 (RFC 8707 bug breaks MCP sign-in); 4.39.23+ is fine.
  3. mcp/sdk is pinned to 0.8.1 and makes BC breaks in most minor releases. Upgrade deliberately after reading its CHANGELOG; all SDK usage is confined to mcp/.
  4. pve-infra → CT 132 webhook was flaky once (a git fetch failure on the first beta push) and slow once (~20s). Worth a look if it recurs.
  5. Migration tracker drift: prod's migrations table records an older file set; 005–007 were applied by hand, and 004 (ticket_watchers.ticket_id int → varchar + FK) is still not applied in prod. It predates this work; deciding when to run it is a separate call.
  6. During phase 1 a single short line of the Authelia OIDC signing key's PEM was accidentally printed into the working session (a fragment, not the full key). Rotating that key at some point is prudent.
## Phase 6 done ✅: MCP server live in production **Shipped** (`9bbe4ef` merge to `main`, docs `dad066c`): - Prod deploy script (`/usr/local/bin/tinker_deploy.sh`) runs `composer install --no-dev ... --classmap-authoritative` after the `.env` restore (backup kept). A failure only affects `/mcp`. - Prod app nginx (pve-infra `cc038c6`): `/mcp` + well-known → `mcp/server.php`; `vendor/`, `mcp/`, `composer.*` → 404. - NPM prod host 14: `/mcp` + `/.well-known/oauth-protected-resource` exempt from forward-auth, `Remote-*` blanked. - README: "MCP server (Claude Code and other MCP clients)" section, plus the updated project tree. **Verified on production:** - Public: `401` challenge with `resource_metadata=https://t.lotusguild.org/.well-known/oauth-protected-resource/mcp`; metadata `resource` = `https://t.lotusguild.org/mcp`; bad or forged credentials refused; web UI still behind Authelia; Bearer API unchanged; internals unreachable. - Real Claude Code sign-in (`claude mcp login tinker --no-browser`), then a fresh session: `search_tickets` → **32**, matching the prod DB's non-Closed count exactly; `get_ticket` on 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), bypassing `download_attachment.php`'s ticket-visibility check for attachments. It now has `location ^~ /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 ```bash claude mcp add --transport http --scope user --client-id tinker-tickets-mcp --callback-port 47823 tinker https://t.lotusguild.org/mcp claude mcp login tinker # --no-browser on headless machines ``` ### Follow-ups (not blocking) 1. **Test ticket #194194263** ("[MCP test] …") can be deleted from the web UI. 2. **Authelia upgrades:** skip 4.39.21/4.39.22 (RFC 8707 bug breaks MCP sign-in); 4.39.23+ is fine. 3. **`mcp/sdk` is pinned to 0.8.1** and makes BC breaks in most minor releases. Upgrade deliberately after reading its CHANGELOG; all SDK usage is confined to `mcp/`. 4. **pve-infra → CT 132 webhook** was flaky once (a `git fetch` failure on the first beta push) and slow once (~20s). Worth a look if it recurs. 5. **Migration tracker drift:** prod's `migrations` table records an older file set; 005–007 were applied by hand, and **004 (`ticket_watchers.ticket_id` int → varchar + FK) is still not applied in prod**. It predates this work; deciding when to run it is a separate call. 6. During phase 1 a single short line of the Authelia OIDC signing key's PEM was accidentally printed into the working session (a fragment, not the full key). Rotating that key at some point is prudent.
jared closed this issue 2026-09-24 19:27:00 -04:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/tinker_tickets#111