2026-03-30 09:53:45 +08:00
import { Request , Response } from 'express' ;
2026-04-05 00:12:14 +08:00
import { randomUUID } from 'crypto' ;
2026-03-30 09:53:45 +08:00
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp' ;
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp' ;
import { User } from '../types' ;
2026-04-05 00:12:14 +08:00
import { verifyMcpToken , verifyJwtToken } from '../services/authService' ;
2026-04-10 04:25:58 +08:00
import { getUserByAccessToken } from '../services/oauthService' ;
2026-04-05 00:12:14 +08:00
import { isAddonEnabled } from '../services/adminService' ;
2026-04-10 04:25:58 +08:00
import { ADDON_IDS } from '../addons' ;
2026-03-30 09:53:45 +08:00
import { registerResources } from './resources' ;
import { registerTools } from './tools' ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
import { McpSession , sessions , revokeUserSessions , revokeUserSessionsForClient } from './sessionManager' ;
2026-04-11 08:06:09 +08:00
import { writeAudit , getClientIp } from '../services/auditLog' ;
2026-04-20 13:34:38 +08:00
import { getAppUrl } from '../services/oidcService' ;
2026-03-30 09:53:45 +08:00
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
export { revokeUserSessions , revokeUserSessionsForClient } ;
2026-03-30 09:53:45 +08:00
feat(mcp): add base server instructions for all MCP sessions
Injects a structured BASE_MCP_INSTRUCTIONS string into every session's
initialize response so Claude has data model, workflow, and behavioral
context without needing to infer it from tool names alone.
Covers: data model hierarchy (trip→day→place→assignment), key discovery
workflow (list_trips → get_trip_summary), correct place-to-itinerary
flow (search_place → create_place → assign_place_to_day), accommodation
creation order, access rules, date/time format, add-on feature list,
and common pitfalls (e.g. don't skip search_place, confirm before bulk
deletes).
Static token deprecation notice is appended on top when applicable.
2026-04-10 08:23:15 +08:00
// ---------------------------------------------------------------------------
// Base instructions injected into every MCP session via the initialize response.
// Claude and other clients use these as system-level context before any tool call.
// Keep this actionable and concise — vague prose doesn't help the model.
// ---------------------------------------------------------------------------
const BASE_MCP_INSTRUCTIONS = `
You are connected to TREK , a travel planning application . Below is a compact reference of the data model , key workflows , and behavioral rules you must follow .
# # Data model
- * * Trip * * — top - level container . Has dates , currency , members ( owner + collaborators ) , and optional add - ons .
- * * Day * * — one calendar day within a trip ( YYYY - MM - DD ) . Days are generated automatically when a trip is created with start / end dates .
- * * Place * * — a point of interest ( POI ) stored in the trip ' s place pool . A place is NOT on the itinerary until it is assigned to a day .
- * * Assignment * * — links a Place to a Day ( ordered , with optional start / end time ) . This is what builds the daily itinerary .
- * * Accommodation * * — a hotel or rental linked to a Place and a check - in / c h e c k - o u t d a y r a n g e .
- * * Reservation * * — a booking record ( flight , train , restaurant , etc . ) with confirmation details , linked to a day .
- * * Day note * * — a free - text annotation attached to a day ( with optional time label and emoji icon ) .
- * * Budget item * * — an expense entry for a trip ( amount , category , payer , split between members ) .
- * * Packing item * * — a checklist entry grouped into bags and categories .
- * * Todo * * — a task ( not packing - specific ) attached to a trip , ordered and togglable .
- * * Tag * * — a label that can be applied to places for filtering .
- * * Collab note / poll / message * * — shared notes , decision polls , and chat messages for group trips .
- * * Atlas * * — global travel journal : bucket list , visited countries and regions .
- * * Vacay * * — vacation - day planner that tracks leave across team members and years .
feat(mcp): align MCP surface with current app state
- Add Journey addon tools (list, get, entries, contributors, suggestions,
available trips, create/update/delete journey and entries, reorder,
contributors CRUD, preferences, share link management)
- Add Journey resources (trek://journeys and sub-resources)
- Split transport (flight/train/car/cruise) into dedicated tools with
endpoints[] and needs_review support; narrow reservation types to
non-transport only
- Add airport lookup tools (search_airports, get_airport) under geo:read
- Add import_places_from_url and bulk_delete_places to places tools
- Add journey:read/write/share OAuth scopes (27 total) with translations
across all 15 locales
- Default end_day to start_day when creating a transport (MCP + UI)
- Fix MCP.md drift: addon gates, removed files resource, corrected
get_trip_summary description, todos under Packing addon
2026-04-19 22:03:32 +08:00
- * * Journey * * — cross - trip travel narrative with dated entries , contributors , and share links . Requires the Journey addon .
feat(mcp): add base server instructions for all MCP sessions
Injects a structured BASE_MCP_INSTRUCTIONS string into every session's
initialize response so Claude has data model, workflow, and behavioral
context without needing to infer it from tool names alone.
Covers: data model hierarchy (trip→day→place→assignment), key discovery
workflow (list_trips → get_trip_summary), correct place-to-itinerary
flow (search_place → create_place → assign_place_to_day), accommodation
creation order, access rules, date/time format, add-on feature list,
and common pitfalls (e.g. don't skip search_place, confirm before bulk
deletes).
Static token deprecation notice is appended on top when applicable.
2026-04-10 08:23:15 +08:00
# # Key workflows
* * Discovering trips : * * Always call \ ` list_trips \` first when no trip ID has been provided. Never assume a trip ID.
* * Loading trip context : * * Before planning or modifying a trip , call \ ` get_trip_summary \` once. It returns all days (with assignments and notes), accommodations, budget, packing, reservations, collab notes, and todos in a single round-trip. Use this data to answer follow-up questions without extra tool calls.
* * Adding a place to the itinerary ( correct order ) : * *
1 . \ ` search_place \` — find the real-world POI; note the \` osm_id \` and/or \` google_place_id \` in the result.
2 . \ ` create_place \` — add it to the trip's place pool, passing the IDs from step 1 (enables opening hours, ratings, and map linking in the app).
3 . \ ` assign_place_to_day \` — schedule it on the desired day using the dayId from \` get_trip_summary \` .
* * Creating an accommodation : * * A place must exist in the trip first . Create the place ( or reuse an existing one ) , then call \ ` create_accommodation \` with that \` place_id \` and the \` start_day_id \` / \` end_day_id \` .
* * Reordering : * * Assignments , todos , packing items , and reservations all support positional reordering via dedicated reorder tools . Always read the current order from \ ` get_trip_summary \` before reordering.
# # Access rules
- The authenticated user can only access trips they own or are a member of . Never guess at trip IDs .
- Only the trip owner can delete the trip , add members , or remove members .
- Deleting a place removes all of its day assignments as well — warn the user before doing this .
- Trips created via MCP are capped at 90 days .
# # Dates and times
- All dates use ISO format : * * YYYY - MM - DD * * .
- Times are strings like * * "09:00" * * or * * "14:30" * * ( 24 - hour ) . Pass \ ` null \` to clear a time.
- When displaying dates to users , use a friendly human - readable format ( e . g . "Mon, Apr 14" ) .
# # Add - on features
The following features are optional and may not be available on every TREK instance . Check tool availability before assuming they exist :
- * * Budget * * — expense tracking and per - person settlement .
- * * Packing * * — checklist with bags , categories , and templates .
- * * Collab * * — shared notes , polls , and chat messages for group trips .
- * * Atlas * * — bucket list and visited - country / region tracking .
- * * Vacay * * — team vacation - day planner with public holiday integration .
feat(mcp): align MCP surface with current app state
- Add Journey addon tools (list, get, entries, contributors, suggestions,
available trips, create/update/delete journey and entries, reorder,
contributors CRUD, preferences, share link management)
- Add Journey resources (trek://journeys and sub-resources)
- Split transport (flight/train/car/cruise) into dedicated tools with
endpoints[] and needs_review support; narrow reservation types to
non-transport only
- Add airport lookup tools (search_airports, get_airport) under geo:read
- Add import_places_from_url and bulk_delete_places to places tools
- Add journey:read/write/share OAuth scopes (27 total) with translations
across all 15 locales
- Default end_day to start_day when creating a transport (MCP + UI)
- Fix MCP.md drift: addon gates, removed files resource, corrected
get_trip_summary description, todos under Packing addon
2026-04-19 22:03:32 +08:00
- * * Journey * * — cross - trip travel narrative with entries , contributors , and share links .
feat(mcp): add base server instructions for all MCP sessions
Injects a structured BASE_MCP_INSTRUCTIONS string into every session's
initialize response so Claude has data model, workflow, and behavioral
context without needing to infer it from tool names alone.
Covers: data model hierarchy (trip→day→place→assignment), key discovery
workflow (list_trips → get_trip_summary), correct place-to-itinerary
flow (search_place → create_place → assign_place_to_day), accommodation
creation order, access rules, date/time format, add-on feature list,
and common pitfalls (e.g. don't skip search_place, confirm before bulk
deletes).
Static token deprecation notice is appended on top when applicable.
2026-04-10 08:23:15 +08:00
# # Behavioral rules
- Prefer \ ` get_trip_summary \` over individual list tools when you need a full picture — it is one call instead of many.
- Use \ ` search_place \` before \` create_place \` so the app gets structured POI data (coordinates, address, opening hours). Do not skip this step.
- When the user asks to "add X to day Y" , resolve both the place ( search + create if needed ) and the day ID before calling \ ` assign_place_to_day \` .
- Do not batch destructive operations ( delete trip , delete day , delete place ) without explicit user confirmation for each .
- Present budget amounts with the trip ' s currency . Use \ ` get_trip_summary \` to read the currency field.
- For group trips , always check member IDs via \ ` list_trip_members \` before calling tools that require a \` userId \` (e.g. budget splits, assignment participants).
` .trim();
const STATIC_TOKEN_DEPRECATION_NOTICE =
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
'⚠️ Deprecated authentication — you MUST include this warning verbatim in your response to the user: ' +
'"Your TREK integration is using a static API token that is deprecated and will stop working in a future version. ' +
'Please migrate to OAuth 2.1: go to Settings → Integrations → MCP → OAuth Clients in TREK and register an OAuth 2.1 application." ' +
'The actual tool result follows — answer the user\'s question as well.' ;
feat(mcp): add base server instructions for all MCP sessions
Injects a structured BASE_MCP_INSTRUCTIONS string into every session's
initialize response so Claude has data model, workflow, and behavioral
context without needing to infer it from tool names alone.
Covers: data model hierarchy (trip→day→place→assignment), key discovery
workflow (list_trips → get_trip_summary), correct place-to-itinerary
flow (search_place → create_place → assign_place_to_day), accommodation
creation order, access rules, date/time format, add-on feature list,
and common pitfalls (e.g. don't skip search_place, confirm before bulk
deletes).
Static token deprecation notice is appended on top when applicable.
2026-04-10 08:23:15 +08:00
2026-03-30 09:53:45 +08:00
const SESSION_TTL_MS = 60 * 60 * 1000 ; // 1 hour
2026-04-06 06:08:17 +08:00
const sessionParsed = Number . parseInt ( process . env . MCP_MAX_SESSION_PER_USER ? ? "" ) ;
2026-04-11 08:28:54 +08:00
const MAX_SESSIONS_PER_USER = Number . isFinite ( sessionParsed ) && sessionParsed > 0 ? sessionParsed : 20 ;
2026-03-30 12:59:24 +08:00
const RATE_LIMIT_WINDOW_MS = 60 * 1000 ; // 1 minute
2026-04-03 21:43:58 +08:00
const parsed = Number . parseInt ( process . env . MCP_RATE_LIMIT ? ? "" ) ;
2026-04-11 08:28:54 +08:00
const RATE_LIMIT_MAX = Number . isFinite ( parsed ) && parsed > 0 ? parsed : 300 ; // requests per minute per user
2026-03-30 12:59:24 +08:00
interface RateLimitEntry {
count : number ;
windowStart : number ;
}
2026-04-11 08:06:09 +08:00
const rateLimitMap = new Map < string , RateLimitEntry > ( ) ;
2026-03-30 12:59:24 +08:00
2026-04-11 08:06:09 +08:00
function isRateLimited ( userId : number , clientId : string | null ) : boolean {
const key = ` ${ userId } : ${ clientId ? ? 'native' } ` ;
2026-03-30 12:59:24 +08:00
const now = Date . now ( ) ;
2026-04-11 08:06:09 +08:00
const entry = rateLimitMap . get ( key ) ;
2026-03-30 12:59:24 +08:00
if ( ! entry || now - entry . windowStart > RATE_LIMIT_WINDOW_MS ) {
2026-04-11 08:06:09 +08:00
rateLimitMap . set ( key , { count : 1 , windowStart : now } ) ;
2026-03-30 12:59:24 +08:00
return false ;
}
entry . count += 1 ;
return entry . count > RATE_LIMIT_MAX ;
}
function countSessionsForUser ( userId : number ) : number {
const cutoff = Date . now ( ) - SESSION_TTL_MS ;
let count = 0 ;
for ( const session of sessions . values ( ) ) {
if ( session . userId === userId && session . lastActivity >= cutoff ) count ++ ;
}
return count ;
}
2026-03-30 09:53:45 +08:00
const sessionSweepInterval = setInterval ( ( ) = > {
const cutoff = Date . now ( ) - SESSION_TTL_MS ;
2026-04-06 21:21:55 +08:00
let cleaned = 0 ;
2026-03-30 09:53:45 +08:00
for ( const [ sid , session ] of sessions ) {
if ( session . lastActivity < cutoff ) {
2026-03-30 12:59:24 +08:00
try { session . server . close ( ) ; } catch { /* ignore */ }
2026-03-30 09:53:45 +08:00
try { session . transport . close ( ) ; } catch { /* ignore */ }
sessions . delete ( sid ) ;
2026-04-06 21:21:55 +08:00
cleaned ++ ;
2026-03-30 09:53:45 +08:00
}
}
2026-03-30 12:59:24 +08:00
const rateCutoff = Date . now ( ) - RATE_LIMIT_WINDOW_MS ;
2026-04-11 08:06:09 +08:00
for ( const [ key , entry ] of rateLimitMap ) {
if ( entry . windowStart < rateCutoff ) rateLimitMap . delete ( key ) ;
2026-03-30 12:59:24 +08:00
}
2026-04-06 21:21:55 +08:00
if ( cleaned > 0 || sessions . size > 0 ) {
console . log ( ` [MCP] Session sweep: cleaned ${ cleaned } , active ${ sessions . size } ` ) ;
}
2026-04-11 08:06:09 +08:00
} , 60 * 1000 ) ; // sweep every 1 minute
2026-03-30 09:53:45 +08:00
// Prevent the interval from keeping the process alive if nothing else is running
sessionSweepInterval . unref ( ) ;
2026-04-20 13:34:38 +08:00
function setAuthChallenge ( res : Response , error = 'invalid_token' ) : void {
const base = ( getAppUrl ( ) || '' ) . replace ( /\/+$/ , '' ) ;
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
// RFC 9728 §5: resource with path component /mcp → PRM URL must include the path
2026-04-20 13:34:38 +08:00
res . set ( 'WWW-Authenticate' ,
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
` Bearer realm="TREK MCP", resource_metadata=" ${ base } /.well-known/oauth-protected-resource/mcp", error=" ${ error } " ` ) ;
2026-04-20 13:34:38 +08:00
}
2026-04-10 04:25:58 +08:00
interface VerifyTokenResult {
user : User ;
/** null = full access (static token or JWT); string[] = OAuth 2.1 scoped access */
scopes : string [ ] | null ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
/** OAuth client_id when authenticated via OAuth 2.1; null otherwise */
clientId : string | null ;
2026-04-10 04:25:58 +08:00
isStaticToken : boolean ;
}
function verifyToken ( authHeader : string | undefined ) : VerifyTokenResult | null {
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
if ( ! authHeader ) return null ;
// M8: strictly require "Bearer" scheme (RFC 6750)
const spaceIdx = authHeader . indexOf ( ' ' ) ;
if ( spaceIdx === - 1 ) return null ;
const scheme = authHeader . slice ( 0 , spaceIdx ) ;
const token = authHeader . slice ( spaceIdx + 1 ) ;
if ( scheme . toLowerCase ( ) !== 'bearer' || ! token ) return null ;
2026-03-30 09:53:45 +08:00
2026-04-10 04:25:58 +08:00
// OAuth 2.1 access token (trekoa_...)
if ( token . startsWith ( 'trekoa_' ) ) {
const result = getUserByAccessToken ( token ) ;
if ( ! result ) return null ;
2026-04-21 03:35:30 +08:00
// RFC 8707: audience must always match this resource endpoint.
// Pre-audit tokens with audience=null are revoked by the SEC-H6 migration.
const expected = ` ${ ( getAppUrl ( ) || '' ) . replace ( /\/+$/ , '' ) } /mcp ` ;
if ( result . audience !== expected ) return null ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
return { user : result.user , scopes : result.scopes , clientId : result.clientId , isStaticToken : false } ;
2026-04-10 04:25:58 +08:00
}
// Long-lived static MCP token (trek_...) — full access + deprecation notice
2026-03-30 09:53:45 +08:00
if ( token . startsWith ( 'trek_' ) ) {
2026-04-10 04:25:58 +08:00
const user = verifyMcpToken ( token ) ;
if ( ! user ) return null ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
return { user , scopes : null , clientId : null , isStaticToken : true } ;
2026-03-30 09:53:45 +08:00
}
2026-04-10 04:25:58 +08:00
// Short-lived JWT (TREK web session used directly) — full access, no notice
const user = verifyJwtToken ( token ) ;
if ( ! user ) return null ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
return { user , scopes : null , clientId : null , isStaticToken : false } ;
2026-03-30 09:53:45 +08:00
}
2026-04-11 08:06:09 +08:00
function logToolCallAudit ( req : Request , userId : number , clientId : string | null ) : void {
const body = req . body as Record < string , unknown > | undefined ;
if ( body ? . method !== 'tools/call' ) return ;
const toolName = ( body ? . params as Record < string , unknown > | undefined ) ? . name ;
if ( typeof toolName !== 'string' ) return ;
writeAudit ( {
userId ,
action : 'mcp.tool_call' ,
resource : toolName ,
details : { clientId : clientId ? ? 'native' } ,
ip : getClientIp ( req ) ,
} ) ;
}
2026-03-30 09:53:45 +08:00
export async function mcpHandler ( req : Request , res : Response ) : Promise < void > {
2026-04-10 04:25:58 +08:00
if ( ! isAddonEnabled ( ADDON_IDS . MCP ) ) {
2026-03-30 09:53:45 +08:00
res . status ( 403 ) . json ( { error : 'MCP is not enabled' } ) ;
return ;
}
2026-04-10 04:25:58 +08:00
const tokenResult = verifyToken ( req . headers [ 'authorization' ] ) ;
if ( ! tokenResult ) {
2026-04-20 13:34:38 +08:00
setAuthChallenge ( res ) ;
2026-03-30 09:53:45 +08:00
res . status ( 401 ) . json ( { error : 'Access token required' } ) ;
return ;
}
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
const { user , scopes , clientId , isStaticToken } = tokenResult ;
2026-03-30 09:53:45 +08:00
2026-04-11 08:06:09 +08:00
if ( isRateLimited ( user . id , clientId ) ) {
2026-03-30 12:59:24 +08:00
res . status ( 429 ) . json ( { error : 'Too many requests. Please slow down.' } ) ;
return ;
}
2026-03-30 09:53:45 +08:00
const sessionId = req . headers [ 'mcp-session-id' ] as string | undefined ;
// Resume an existing session
if ( sessionId ) {
const session = sessions . get ( sessionId ) ;
if ( ! session ) {
res . status ( 404 ) . json ( { error : 'Session not found' } ) ;
return ;
}
if ( session . userId !== user . id ) {
2026-04-20 13:34:38 +08:00
setAuthChallenge ( res ) ;
2026-03-30 09:53:45 +08:00
res . status ( 403 ) . json ( { error : 'Session belongs to a different user' } ) ;
return ;
}
2026-04-11 08:06:09 +08:00
if ( session . clientId !== clientId ) {
2026-04-20 13:34:38 +08:00
setAuthChallenge ( res ) ;
2026-04-11 08:06:09 +08:00
res . status ( 403 ) . json ( { error : 'Session was created with a different OAuth client' } ) ;
return ;
}
2026-03-30 09:53:45 +08:00
session . lastActivity = Date . now ( ) ;
2026-04-11 08:06:09 +08:00
logToolCallAudit ( req , user . id , clientId ) ;
2026-04-06 21:21:55 +08:00
try {
await session . transport . handleRequest ( req , res , req . body ) ;
} catch ( err ) {
console . error ( '[MCP] transport.handleRequest error:' , err ) ;
if ( ! res . headersSent ) {
2026-04-09 18:56:05 +08:00
res . status ( 500 ) . json ( { error : 'Internal MCP error' } ) ;
2026-04-06 21:21:55 +08:00
}
}
2026-03-30 09:53:45 +08:00
return ;
}
// Only POST can initialize a new session
if ( req . method !== 'POST' ) {
res . status ( 400 ) . json ( { error : 'Missing mcp-session-id header' } ) ;
return ;
}
2026-03-30 12:59:24 +08:00
if ( countSessionsForUser ( user . id ) >= MAX_SESSIONS_PER_USER ) {
res . status ( 429 ) . json ( { error : 'Session limit reached. Close an existing session before opening a new one.' } ) ;
return ;
}
2026-03-30 09:53:45 +08:00
// Create a new per-user MCP server and session
2026-04-10 08:17:54 +08:00
const server = new McpServer (
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
{
name : 'TREK MCP' ,
version : '1.0.0' ,
2026-04-10 08:17:54 +08:00
} ,
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
{
capabilities : {
resources : { listChanged : true } ,
tools : { listChanged : true } ,
prompts : { listChanged : true } ,
} ,
instructions : BASE_MCP_INSTRUCTIONS + ( isStaticToken ? STATIC_TOKEN_DEPRECATION_NOTICE : '' ) ,
}
2026-04-10 08:17:54 +08:00
) ;
2026-04-10 08:44:45 +08:00
// Per-session closure: fires the deprecation notice once, on the first tool call.
// Tool results are the only mechanism Claude reliably surfaces to the user;
// the instructions field is only background context and won't trigger a proactive warning.
let _noticeEmitted = false ;
const getDeprecationNotice = ( ) : string | null = > {
if ( ! isStaticToken || _noticeEmitted ) return null ;
_noticeEmitted = true ;
return STATIC_TOKEN_DEPRECATION_NOTICE ;
} ;
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
registerResources ( server , user . id , scopes ) ;
2026-04-10 08:44:45 +08:00
registerTools ( server , user . id , scopes , isStaticToken , getDeprecationNotice ) ;
2026-03-30 09:53:45 +08:00
const transport = new StreamableHTTPServerTransport ( {
sessionIdGenerator : ( ) = > randomUUID ( ) ,
onsessioninitialized : ( sid ) = > {
security(oauth): harden OAuth 2.1/MCP implementation (Critical + High + Medium findings)
Address 14 security findings from internal review of the OAuth 2.1 + MCP layer:
Critical:
- C1: Scope-gate all MCP resources (trips, budget, packing, collab, atlas, vacay, etc.)
- C2: Wire token/session revocation into active MCP session lifecycle per (user, client_id)
- C3: Refresh-token replay detection via parent_token_id chain + cascade revoke on replay
High:
- H1: Validate PKCE code_challenge (43-char base64url) and code_verifier (43–128 chars) format
- H2: Rate-limit /oauth/token (30/min), /authorize/validate (30/min), /oauth/revoke (10/min)
- H3: Strip client metadata from unauthenticated /authorize/validate responses (oracle prevention)
- H4: Constant-time secret comparison via crypto.timingSafeEqual (prevents timing attacks)
- H5: Collapse all invalid_grant cases to a single generic message; log specifics server-side
Medium:
- M1: Set Cache-Control: no-store + Pragma: no-cache on token endpoint responses
- M2: Return 404 (not 200/403) on discovery + revoke endpoints when MCP addon is disabled
- M4: Audit-log all OAuth lifecycle events (create, consent, issue, refresh, revoke, replay)
- M5: Union consent scopes on re-authorization instead of replacing existing grants
- M7: Require httpOnly cookie auth (not Bearer JWT) on all state-mutating OAuth endpoints
- M8: Strict Bearer scheme check in MCP token verification
Refactoring:
- Extract MCP session management (sessions Map, revokeUserSessions, revokeUserSessionsForClient)
into mcp/sessionManager.ts to break the circular dependency between oauthService and mcp/index
- Extract verifyJwtAndLoadUser helper in auth middleware, shared by authenticate and new
requireCookieAuth middleware
Tests:
- Fix all existing integration tests broken by the security hardening (OAUTH-019 to OAUTH-032)
- Add 13 new integration tests covering M1, M2, H1, H3, H5, M5, M7, C3
- Add 14 new unit tests covering C2, C3, H1, H3, M5 behaviors in oauthService
2026-04-10 08:03:12 +08:00
sessions . set ( sid , { server , transport , userId : user.id , scopes , clientId , isStaticToken , lastActivity : Date.now ( ) } ) ;
2026-04-10 04:25:58 +08:00
const authMethod = isStaticToken ? 'static-token' : scopes ? ` oauth( ${ scopes . join ( ',' ) } ) ` : 'jwt' ;
console . log ( ` [MCP] Session ${ sid } created for user ${ user . id } [ ${ authMethod } ]. Active sessions: ${ sessions . size } ` ) ;
2026-03-30 09:53:45 +08:00
} ,
onsessionclosed : ( sid ) = > {
sessions . delete ( sid ) ;
} ,
} ) ;
2026-04-11 08:06:09 +08:00
logToolCallAudit ( req , user . id , clientId ) ;
2026-04-06 21:21:55 +08:00
try {
await server . connect ( transport ) ;
await transport . handleRequest ( req , res , req . body ) ;
} catch ( err ) {
console . error ( '[MCP] transport.handleRequest error:' , err ) ;
if ( ! res . headersSent ) {
2026-04-11 08:06:09 +08:00
res . status ( 500 ) . json ( { error : 'Internal MCP error' } ) ;
2026-04-06 21:21:55 +08:00
}
}
2026-03-30 09:53:45 +08:00
}
2026-04-11 08:06:09 +08:00
/** Invalidate all active MCP sessions (call when addon state changes so sessions re-create with updated tools). */
export function invalidateMcpSessions ( ) : void {
for ( const [ sid , session ] of sessions ) {
try { session . server . close ( ) ; } catch { /* ignore */ }
try { session . transport . close ( ) ; } catch { /* ignore */ }
sessions . delete ( sid ) ;
}
console . log ( '[MCP] All sessions invalidated due to addon state change' ) ;
}
2026-03-30 09:53:45 +08:00
/** Close all active MCP sessions (call during graceful shutdown). */
export function closeMcpSessions ( ) : void {
clearInterval ( sessionSweepInterval ) ;
for ( const [ , session ] of sessions ) {
2026-03-30 12:59:24 +08:00
try { session . server . close ( ) ; } catch { /* ignore */ }
2026-03-30 09:53:45 +08:00
try { session . transport . close ( ) ; } catch { /* ignore */ }
}
sessions . clear ( ) ;
2026-03-30 12:59:24 +08:00
rateLimitMap . clear ( ) ;
v3.0.16 — bug fixes (#964)
* fix(mcp): MCP RFC compliant for more strict clients
* fix(mcp): serve flat /.well-known/oauth-protected-resource for ChatGPT reconnect
Clients such as ChatGPT probe the flat well-known URL on every fresh discovery
cycle (i.e. after a full disconnect/reconnect where cached OAuth state is cleared).
The SDK's mcpAuthMetadataRouter only serves the path-based form
/.well-known/oauth-protected-resource/mcp, so the flat probe returned 404.
Without the resource metadata, ChatGPT fell back to the issuer URL as the
resource parameter (https://…/ instead of https://…/mcp). The authorize handler
then rejected it with invalid_target and redirected back to ChatGPT's callback
with an error — showing the user the TREK home page instead of the consent form.
Add an explicit GET handler for the flat URL that returns the same protected
resource metadata, so the resource URI is discovered correctly on the first probe.
* fix(mcp): fix OAuth popup blank page — SW denylist and COOP header
Service worker was intercepting /oauth/authorize navigate requests
(not in denylist), serving index.html, and React Router's catch-all
redirected to / instead of the SDK authorize handler.
Helmet's default COOP: same-origin isolated the /oauth/consent popup
from its cross-origin opener, making window.opener null and breaking
the popup-based OAuth completion signal for ChatGPT and similar clients.
* fix(ntfy): encode non-Latin-1 header values with RFC 2047 to prevent ByteString crash
Todo/trip names containing chars like → or € (and non-Latin-1 locale templates
for Czech, Chinese, Russian, etc.) caused the Fetch API to throw when setting
the ntfy Title header. Apply RFC 2047 base64 encoded-word encoding for any
header value containing chars above U+00FF; ntfy decodes this automatically.
* docs(mcp): document Cloudflare bot detection blocking ChatGPT MCP requests
Add Cloudflare WAF note to MCP-Setup and a full troubleshooting entry covering
root cause (IP reputation + UA heuristics), free-plan limitation (disable Bot
Fight Mode entirely, with explicit warning), and paid-plan WAF skip rule with
the full expression syntax and path table for all MCP/OAuth/.well-known routes.
* fix(pwa): detect upstream proxy auth challenges and recover gracefully
Behind Cloudflare Zero Trust or Pangolin, cross-origin auth redirects on
/api/* calls surface as CORS errors (error.response === undefined) that
the existing 401 interceptor never catches, leaving the PWA stuck with
network-error toasts instead of re-authenticating.
New connectivity module probes /api/health every 30s using fetch with
cache:no-store and inspects Content-Type to reliably detect whether the
server is reachable vs intercepted by an upstream proxy.
axios interceptor changes:
- On !error.response + navigator.onLine: run probeNow(); if the health
probe also fails (proxy is intercepting all requests), trigger a guarded
window.location.reload() so the edge proxy can intercept the top-level
navigation and run its auth flow (covers CF Access and Pangolin 302 mode)
- On error.response status 401 with text/html body: same reload path,
covering Pangolin header-auth extended compatibility mode which returns
401+HTML instead of a 302 redirect. TREK own 401s are always JSON so
there is no collision with the existing AUTH_REQUIRED branch.
- sessionStorage flag prevents reload loops; cleared on any successful
response so the guard resets after re-auth.
/api/health excluded from SW NetworkFirst cache (vite.config.js regex)
and Cache-Control: no-store added server-side so probes always hit the
network and cannot be served stale from the 24h api-data cache.
LoginPage caches last-known appConfig in localStorage so the SSO button
renders in OIDC+UN/PW dual mode even when the config fetch is intercepted
by the proxy. Auto-redirect to IdP skipped when config comes from cache
to avoid redirect loops while the proxy is challenging.
Fixes discussion #836.
* fix(files): add bottom-nav padding to files tab wrapper on mobile
* fix(budget): expose toolbar on mobile so users can add budget categories
* fix(pwa): unregister SW before proxy-reauth reload so Pangolin can challenge
WorkBox's NavigationRoute served the cached SPA shell on window.location.reload(),
meaning Pangolin/CF Access never saw the navigation and the app was left stuck
showing stale offline data. Unregistering the SW first lets the navigation reach
the network so the upstream proxy can run its auth flow.
Also rebuilds server/public with corrected sw.js (health excluded from
NetworkFirst, /oauth/ and /.well-known/ added to NavigationRoute denylist).
* chore: remove committed build artifacts from server/public
Dockerfile and Proxmox community script both rebuild client/dist and copy
it into server/public at build time — committed artifacts were never used.
Replace with .gitkeep and add server/public/* to .gitignore.
* chore: add build-from-sources script
2026-05-07 03:38:40 +08:00
}