## Summary OAuth discovery metadata (RFC 9728 protected-resource, RFC 8414 authorization-server) and the MCP `WWW-Authenticate` header were hardcoded to `SERVER_URL`. This breaks MCP clients that paste any URL other than `api.twenty.com/mcp` — the metadata declares `resource: https://api.twenty.com/mcp`, which doesn't match the URL the client connected to, so the client rejects it and the OAuth flow never starts. Reproduced with Claude's MCP integration: pasting `<workspace>.twenty.com/mcp`, `app.twenty.com/mcp`, or a custom domain returned *"Couldn't reach the MCP server"* because discovery returned a resource URL for a different host. Related memory: MCP clients POST to the URL the user entered, not the discovered resource URL — so every paste-able hostname has to advertise `resource` for that same hostname. ## What the server now does `WorkspaceDomainsService.getValidatedRequestBaseUrl(req)` resolves the canonical base URL for the host the request came in on, validated against the set of hosts we actually serve: - `SERVER_URL` (e.g. `api.twenty.com`) — API host - default base URL (e.g. `app.twenty.com`) — the `DEFAULT_SUBDOMAIN` base - `FRONTEND_URL` bare host - any `<workspace>.twenty.com` subdomain (DB lookup) - any workspace `customDomain` where `isCustomDomainEnabled = true` - any registered `publicDomain` An unrecognized / spoofed Host falls back to `DomainServerConfigService.getBaseUrl()`. **We never reflect arbitrary Host values into the response.** Callers updated: - `OAuthDiscoveryController.getProtectedResourceMetadata` — echoes the validated host into `resource` and `authorization_servers`. - `OAuthDiscoveryController.getAuthorizationServerMetadata` — uses the validated host for `issuer` and `*_endpoint`, **except** `authorization_endpoint`: when the request came in via `SERVER_URL` (API-only, no `/authorize` route), we keep that one pointed at the default frontend base URL. - `McpAuthGuard` — sets `WWW-Authenticate: Bearer resource_metadata=\"<validatedBase>/.well-known/oauth-protected-resource\"` on 401s, so the MCP client's follow-up discovery fetch lands on the same host it started on. ## Security - Workspace identity is already bound to the JWT via per-workspace signing secrets (`jwtWrapperService.generateAppSecret(tokenType, workspaceId)`). Host-aware discovery does not weaken that. - Custom domains are only accepted once `isCustomDomainEnabled = true` (i.e. after DNS verification), so an attacker can't register a custom-domain mapping on a workspace and have discovery reflect it before it's been proven. - Unknown / spoofed Hosts fall through to the default base URL. ## Drive-by Fixed a duplicate `DomainServerConfigModule` import in `application-oauth.module.ts` while adding `WorkspaceDomainsModule`. ## Companion infra change required for custom domains Customer custom domains (`crm.acme.com/mcp`) also require an ingress-level fix to exclude `/mcp`, `/oauth`, and `/.well-known` from the `/s\$uri` rewrite applied when `X-Twenty-Public-Domain: true`. Shipping that in a twenty-infra PR (will cross-link here). ## Test plan - [x] 14 new tests in `WorkspaceDomainsService.getValidatedRequestBaseUrl` covering: missing Host, SERVER_URL, base URL, FRONTEND_URL, workspace subdomain, unknown subdomain fallback, enabled custom domain, disabled custom domain, public domain, completely unrecognized host, lowercase coercion, malformed Host, single-workspace mode fallback, DB throwing → fallback - [x] New `oauth-discovery.controller.spec.ts` covering both endpoints across api / app / workspace-subdomain / custom-domain hosts, plus `cli_client_id` propagation - [x] Rewrote `mcp-auth.guard.spec.ts` to cover `WWW-Authenticate` for all four host types (api, workspace subdomain, custom domain, spoofed fallback) - [x] `yarn jest --testPathPatterns=\"workspace-domains.service|oauth-discovery.controller|mcp-auth.guard\"` → 41/41 passing - [x] `tsc --noEmit` clean on all modified files - [ ] Manual verification against staging: connect Claude to `api.twenty.com/mcp`, `app.twenty.com/mcp`, `<workspace>.twenty.com/mcp`, and a custom domain and confirm OAuth flow completes on each 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The #1 Open-Source CRM
🌐 Website · 📚 Documentation · Roadmap ·
Discord ·
Figma
Installation
See: 🚀 Self-hosting 🖥️ Local Setup
Why Twenty
We built Twenty for three reasons:
CRMs are too expensive, and users are trapped. Companies use locked-in customer data to hike prices. It shouldn't be that way.
A fresh start is required to build a better experience. We can learn from past mistakes and craft a cohesive experience inspired by new UX patterns from tools like Notion, Airtable or Linear.
We believe in open-source and community. Hundreds of developers are already building Twenty together. Once we have plugin capabilities, a whole ecosystem will grow around it.
What You Can Do With Twenty
Please feel free to flag any specific needs you have by creating an issue.
Below are a few features we have implemented to date:
- Personalize layouts with filters, sort, group by, kanban and table views
- Customize your objects and fields
- Create and manage permissions with custom roles
- Automate workflow with triggers and actions
- Emails, calendar events, files, and more
Personalize layouts with filters, sort, group by, kanban and table views
Customize your objects and fields
Create and manage permissions with custom roles
Automate workflow with triggers and actions
Emails, calendar events, files, and more
Stack
- TypeScript
- Nx
- NestJS, with BullMQ, PostgreSQL, Redis
- React, with Jotai, Linaria and Lingui
Thanks
Thanks to these amazing services that we use and recommend for UI testing (Chromatic), code review (Greptile), catching bugs (Sentry) and translating (Crowdin).
Join the Community
- Star the repo
- Subscribe to releases (watch -> custom -> releases)
- Follow us on Twitter or LinkedIn
- Join our Discord
- Improve translations on Crowdin
- Contributions are, of course, most welcome!




