feat(mcp): allowlist Grok's connector callback and document the Grok path (#2158)

* feat(mcp): allowlist Grok's connector callback and document the Grok path

Grok custom connectors self-register through /api/mcp-oauth/register with
redirect_uri https://grok.com/connectors-oauth-exchange-code/, which the
built-in allowlist rejected with invalid_redirect_uri before consent. Add
the callback as an exact-path BUILT_IN_PATTERNS entry (trailing slash
optional, no prefix) with provider 'grok', named "Grok (xAI)" on the
consent page. Tests: accept, foreign-host and other-path rejection,
provider mapping, and a register route test for the Grok DCR shape.

Surface Grok next to ChatGPT: a "Using Grok?" side door on the onboarding
Claude step (one side door open at a time, telemetry step grok), a Grok row
under "Other clients" in the API & MCP settings tab using ?client=grok, and
sv/en strings for both. Docs: mcp-server rule, ARCHITECTURE, README,
registry entry (install section), DECISIONS.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa
Signed-off-by: Emil <emilmattsson14@gmail.com>

* fix(mcp): cite X Corp's published Grok callback, test the consent label

Review pass on #2158: the allowlist comment and DECISIONS entry claimed
xAI publishes no callback and the value came from a live observation; X
Corp lists https://grok.com/connectors-oauth-exchange-code/ as the "Grok
(web)" redirect URL at docs.x.com/x-ads-api/mcp, and grok.com serves the
path itself (slash form 308s to no-slash on the same origin). Reworded
both to cite that. Adds the consent-page test for "Grok (xAI)" next to
the ChatGPT one and a JSDoc on the onboarding side-door toggle.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EGbspj3hiNqvqTWZqdwysa
Signed-off-by: Emil <emilmattsson14@gmail.com>

---------

Signed-off-by: Emil <emilmattsson14@gmail.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mattsson
2026-09-02 14:42:39 +02:00
committed by GitHub
co-authored by Claude Fable 5.1
parent 61a76b1669
commit 6a85efb00a
14 changed files with 131 additions and 32 deletions
+35 -20
View File
@@ -47,6 +47,12 @@ interface NewUserChecklistProps {
sieSweep?: { auto_linked: number; suggested: number; unmatched: number; errors: number } | null
}
/** Clients that get a collapsed "Using X?" side door under the Claude step.
* Each value keys the i18n strings step_claude_<door>_link / _steps and the
* telemetry step name. Order is display order. */
const SIDE_DOORS = ['chatgpt', 'grok'] as const
type SideDoor = (typeof SIDE_DOORS)[number]
/**
* Activation funnel events, mirroring the one existing product-event site
* (lib/support/submit-feedback.ts): guarded, try/caught, no PII in
@@ -108,9 +114,9 @@ export default function NewUserChecklist({
// effect and re-raised the error toast forever. The next visit tries once
// more from server truth.
const completeRejectedRef = useRef(false)
// The ChatGPT side door on the Claude step: collapsed by default so the
// one-click Claude path stays the visual primary.
const [chatGptOpen, setChatGptOpen] = useState(false)
// The ChatGPT and Grok side doors on the Claude step: collapsed by default
// so the one-click Claude path stays the visual primary; at most one open.
const [sideDoor, setSideDoor] = useState<SideDoor | null>(null)
const [serverUrlCopied, setServerUrlCopied] = useState(false)
const hasMigration = ENABLED_EXTENSION_IDS.has('arcim-migration')
@@ -258,12 +264,16 @@ export default function NewUserChecklist({
'noopener',
)
}
// ChatGPT has no add-connector deep link (the user pastes the server URL
// into Developer mode manually), so the side door copies the URL instead.
const toggleChatGpt = () => {
setChatGptOpen((open) => {
if (!open) captureSetup('onboarding_setup_step_started', { step: 'chatgpt' })
return !open
/**
* Open one side door (closing any other) or close it when it is already
* open. Neither ChatGPT nor Grok has an add-connector deep link (the user
* pastes the server URL into the client manually), so the side doors copy
* the URL instead. Telemetry fires once per open, never on close.
*/
const toggleSideDoor = (door: SideDoor) => {
setSideDoor((open) => {
if (open !== door) captureSetup('onboarding_setup_step_started', { step: door })
return open === door ? null : door
})
}
const copyServerUrl = async () => {
@@ -467,18 +477,23 @@ export default function NewUserChecklist({
{t('step_claude_guide_link')}
</a>
</p>
<button
type="button"
onClick={toggleChatGpt}
aria-expanded={chatGptOpen}
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t('step_claude_chatgpt_link')}
</button>
{chatGptOpen && (
<div className="flex flex-wrap gap-x-4 gap-y-1">
{SIDE_DOORS.map((door) => (
<button
key={door}
type="button"
onClick={() => toggleSideDoor(door)}
aria-expanded={sideDoor === door}
className="text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors hover:text-foreground"
>
{t(`step_claude_${door}_link`)}
</button>
))}
</div>
{sideDoor && (
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-2">
<p className="max-w-prose text-xs leading-5 text-muted-foreground">
{t('step_claude_chatgpt_steps', { appName })}
{t(`step_claude_${sideDoor}_steps`, { appName })}
</p>
<Button size="sm" variant="outline" onClick={() => void copyServerUrl()}>
{serverUrlCopied
@@ -533,7 +548,7 @@ function Step({
doneNote?: React.ReactNode
/** Block content below the pitch while the step is open. Unlike `children`
* (which lives inside a <p>), this may hold nested block elements, e.g.
* the Claude step's ChatGPT side door. */
* the Claude step's ChatGPT and Grok side doors. */
footnote?: React.ReactNode
last?: boolean
children: React.ReactNode
+10
View File
@@ -422,6 +422,16 @@ export function ApiKeysPanel() {
<CopyBlock text={mcpUrl('claude-connector')} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">Grok</p>
<p className="mb-2 text-xs text-muted-foreground">
{t.rich('grok_instructions', {
path: (chunks) => <strong>{chunks}</strong>,
})}
</p>
<CopyBlock text={mcpUrl('grok')} copyAriaLabel={t('copy_aria')} />
</div>
<div>
<p className="mb-1 text-sm">{t('claude_plugin_label')}</p>
<p className="mb-2 text-xs text-muted-foreground">{t('claude_plugin_instructions')}</p>