fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s

- /oauth/register: drop resource_required check, accept generic
  registrations (Claude Desktop omits resource in DCR body per spec).
  serverId stored as NULL; /authorize still enforces org-ownership
  + access-token aud claim still pinned to resource. Fixes Claude
  Desktop DCR failure (ofid_d7e39530c109fa7f).
- /oauth/authorize: skip strict server.id check when client.serverId
  is NULL (generic client); org check remains the security boundary.
- schema: oauth_clients.server_id no longer NOT NULL.
- migration 0002: ALTER COLUMN server_id DROP NOT NULL (already
  applied on prod).
- install-snippets: add Claude Code (CLI), VS Code, Codex, raw URL
  tabs. Claude Desktop now shows form-field values (Name / Remote MCP
  Server URL / OAuth Client ID / Secret) matching the new Custom
  Connector UI instead of the obsolete JSON config.
- types: InstallTarget enum extended.
- hero-video: clicking the audio toggle restarts the video from
  frame 0 so unmute aligns with the spoken opening.
- marketing: drop em-dashes from rendered copy.
This commit is contained in:
Marco Sadjadi 2026-05-28 17:20:01 +02:00
parent e75f9ad4fe
commit 3a05766f88
9 changed files with 172 additions and 38 deletions

View File

@ -103,13 +103,20 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
const parsed = Body.safeParse(req.body); const parsed = Body.safeParse(req.body);
if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' }); if (!parsed.success) return reply.code(400).send({ error: 'invalid_request' });
// RFC 7591 makes `resource` optional in the registration request body.
// Claude Desktop and several other MCP clients perform a generic
// registration first and only declare the resource later during the
// authorization request (RFC 8707). When a resource is provided we
// bind the client to that server; otherwise we accept a generic
// registration and let /oauth/authorize enforce the resource → org
// check on every authorization. The token endpoint additionally
// pins the audience claim to the resource, so a generic client still
// can't mint a token usable against a server the user does not own.
let serverId: string | null = null; let serverId: string | null = null;
if (parsed.data.resource) { if (parsed.data.resource) {
const server = await resolveServerByResource(parsed.data.resource); const server = await resolveServerByResource(parsed.data.resource);
if (!server) return reply.code(400).send({ error: 'invalid_resource' }); if (!server) return reply.code(400).send({ error: 'invalid_resource' });
serverId = server.id; serverId = server.id;
} else {
return reply.code(400).send({ error: 'resource_required' });
} }
const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`; const clientId = `bmm_${crypto.randomBytes(12).toString('hex')}`;
@ -166,7 +173,16 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' }); if (!redirectOk) return reply.code(400).send({ error: 'invalid_redirect_uri' });
const server = await resolveServerByResource(parsed.data.resource); const server = await resolveServerByResource(parsed.data.resource);
if (!server || server.id !== client.serverId) { if (!server) {
return reply.code(400).send({ error: 'invalid_resource' });
}
// Clients that registered against a specific server (`client.serverId`
// set) must keep authorizing against the same one. Clients that
// registered generically (`client.serverId === null`, e.g. Claude
// Desktop after RFC 7591 DCR) can authorize against any server the
// logged-in user actually owns — the org check below is the real
// boundary.
if (client.serverId !== null && server.id !== client.serverId) {
return reply.code(400).send({ error: 'invalid_resource' }); return reply.code(400).send({ error: 'invalid_resource' });
} }
if (server.orgId !== user.orgId) { if (server.orgId !== user.orgId) {

View File

@ -53,7 +53,7 @@ const EXAMPLES: ExampleEntry[] = [
{ title: 'PostgreSQL', desc: 'Read-only access to your tables with schema introspection.', Icon: PostgresIcon, bg: '#336791', fg: '#ffffff' }, { title: 'PostgreSQL', desc: 'Read-only access to your tables with schema introspection.', Icon: PostgresIcon, bg: '#336791', fg: '#ffffff' },
{ title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.', Icon: SalesforceCloudIcon, bg: '#00a1e0', fg: '#ffffff' }, { title: 'Salesforce', desc: 'Query opportunities, accounts and leads from Claude.', Icon: SalesforceCloudIcon, bg: '#00a1e0', fg: '#ffffff' },
{ title: 'Notion', desc: 'Search pages, read content, append blocks.', Icon: NotionIcon, bg: '#ffffff', fg: '#0a0a0b' }, { title: 'Notion', desc: 'Search pages, read content, append blocks.', Icon: NotionIcon, bg: '#ffffff', fg: '#0a0a0b' },
{ title: 'GitHub', desc: 'List issues, search code, post comments — scoped to one repo.', Icon: GitHubIcon, bg: '#181717', fg: '#ffffff' }, { title: 'GitHub', desc: 'List issues, search code, post comments. Scoped to one repo.', Icon: GitHubIcon, bg: '#181717', fg: '#ffffff' },
{ title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).', Icon: StripeIcon, bg: '#635bff', fg: '#ffffff' }, { title: 'Stripe', desc: 'Look up charges, customers, refunds (read-only by default).', Icon: StripeIcon, bg: '#635bff', fg: '#ffffff' },
{ title: 'Custom REST',desc: 'Wrap any HTTP API behind one prompt-defined tool surface.', Icon: RestIcon, bg: '#6366f1', fg: '#ffffff' }, { title: 'Custom REST',desc: 'Wrap any HTTP API behind one prompt-defined tool surface.', Icon: RestIcon, bg: '#6366f1', fg: '#ffffff' },
]; ];
@ -90,7 +90,7 @@ const MOCK_TEMPLATES: MockTemplate[] = [
const MARKETPLACE_POINTS: { t: string; d: string }[] = [ const MARKETPLACE_POINTS: { t: string; d: string }[] = [
{ {
t: 'Fork and own', t: 'Fork and own',
d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy — no prompt required.', d: 'Start from a server someone already shipped. Fork it, paste your own credentials, deploy. No prompt required.',
}, },
{ {
t: 'Secrets never travel', t: 'Secrets never travel',
@ -98,7 +98,7 @@ const MARKETPLACE_POINTS: { t: string; d: string }[] = [
}, },
{ {
t: 'Ranked by real usage', t: 'Ranked by real usage',
d: 'Templates rise on fork count and active deploys not vanity stars. The useful ones surface themselves.', d: 'Templates rise on fork count and active deploys, not vanity stars. The useful ones surface themselves.',
}, },
]; ];
@ -160,7 +160,7 @@ export default function Landing() {
<div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12"> <div className="relative z-10 mx-auto grid w-full max-w-6xl gap-10 px-6 py-14 sm:py-20 md:grid-cols-[1.05fr_1fr] md:items-center md:gap-12">
<div className="min-w-0"> <div className="min-w-0">
<span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]"> <span className="mono inline-block rounded-full border border-[--color-border] bg-[--color-bg-elevated] px-2.5 py-0.5 text-[11px] tracking-wide text-[--color-fg-muted]">
v0.1 updated 2026-05-20 v0.1 · updated 2026-05-20
</span> </span>
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]"> <h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
Describe your tool. Describe your tool.
@ -337,7 +337,7 @@ export default function Landing() {
</h2> </h2>
</div> </div>
<p className="max-w-xs text-[14px] leading-relaxed text-[--color-fg-muted]"> <p className="max-w-xs text-[14px] leading-relaxed text-[--color-fg-muted]">
Real integrations our customers ship today built from one prompt each. Real integrations our customers ship today. Built from one prompt each.
</p> </p>
</div> </div>

View File

@ -30,7 +30,7 @@ const COLORS = {
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [ const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
[ [
'// MCP server — auto-generated', '// MCP server (auto-generated)',
'import { Server } from "@bmm/mcp";', 'import { Server } from "@bmm/mcp";',
'', '',
'const server = new Server({', 'const server = new Server({',

View File

@ -85,7 +85,12 @@ export function HeroVideo() {
const next = !v.muted; const next = !v.muted;
v.muted = next; v.muted = next;
setMuted(next); setMuted(next);
if (!next && v.paused) { // Restart from frame 0 whenever the audio state toggles. Visitors who
// unmute want to hear the opening, not whatever moment the video was
// already at; visitors who re-mute also expect a clean restart so the
// animation lines up with the silent loop again.
v.currentTime = 0;
if (v.paused) {
v.play().catch(() => undefined); v.play().catch(() => undefined);
} }
}, []); }, []);
@ -155,7 +160,7 @@ export function HeroVideo() {
color: 'var(--color-fg-muted)', color: 'var(--color-fg-muted)',
}} }}
> >
your browser blocked playback open the video directly your browser blocked playback. open the video directly
<ExternalLink size={12} /> <ExternalLink size={12} />
</a> </a>
)} )}

View File

@ -8,8 +8,12 @@ import type { InstallTarget } from '@bmm/types';
const TABS: { id: InstallTarget; label: string }[] = [ const TABS: { id: InstallTarget; label: string }[] = [
{ id: 'claude-desktop', label: 'Claude Desktop' }, { id: 'claude-desktop', label: 'Claude Desktop' },
{ id: 'claude-code', label: 'Claude Code' },
{ id: 'cursor', label: 'Cursor' }, { id: 'cursor', label: 'Cursor' },
{ id: 'vscode', label: 'VS Code' },
{ id: 'chatgpt', label: 'ChatGPT' }, { id: 'chatgpt', label: 'ChatGPT' },
{ id: 'codex', label: 'Codex' },
{ id: 'raw-url', label: 'Other' },
]; ];
export function InstallSnippets({ input }: { input: SnippetInput }) { export function InstallSnippets({ input }: { input: SnippetInput }) {
@ -17,7 +21,7 @@ export function InstallSnippets({ input }: { input: SnippetInput }) {
const snippet = buildSnippet(tab, input); const snippet = buildSnippet(tab, input);
return ( return (
<div> <div>
<div className="flex gap-1 border-b border-[--color-border]"> <div className="flex flex-wrap gap-1 border-b border-[--color-border]">
{TABS.map((t) => ( {TABS.map((t) => (
<button <button
key={t.id} key={t.id}

View File

@ -4,29 +4,69 @@ export interface SnippetInput {
name: string; name: string;
slug: string; slug: string;
publicUrl: string; publicUrl: string;
/**
* Optional pre-issued OAuth client credentials. When present, Claude
* Desktop / ChatGPT users can paste them into the "OAuth Client ID /
* Secret" form fields as a fallback if Dynamic Client Registration
* (DCR) is blocked by their network or the client refuses it.
*/
oauthClientId?: string;
oauthClientSecret?: string;
} }
export function buildSnippet(target: InstallTarget, input: SnippetInput): { label: string; code: string; note?: string } { export interface SnippetOutput {
label: string;
code: string;
/** Optional short hint shown below the snippet. */
note?: string;
}
const stripTrailingSlash = (u: string) => u.replace(/\/$/, '');
export function buildSnippet(target: InstallTarget, input: SnippetInput): SnippetOutput {
const key = input.slug.replace(/[^a-zA-Z0-9_-]/g, '_'); const key = input.slug.replace(/[^a-zA-Z0-9_-]/g, '_');
const mcpUrl = `${input.publicUrl.replace(/\/$/, '')}/mcp`; const baseUrl = stripTrailingSlash(input.publicUrl);
const mcpUrl = `${baseUrl}/mcp`;
const hasCreds = !!(input.oauthClientId && input.oauthClientSecret);
switch (target) { switch (target) {
case 'claude-desktop': case 'claude-desktop': {
// Claude Desktop now uses a "Custom Connector" UI with form fields:
// Name / Remote MCP Server URL / OAuth Client ID / OAuth Client Secret.
// We show the exact values to paste so the JSON-config dance is gone.
// OAuth fields are blank by default (DCR registers them automatically)
// and only filled when the org has pre-issued credentials as a fallback.
const lines = [
`Name: ${input.name}`,
`Remote MCP Server URL: ${mcpUrl}`,
];
if (hasCreds) {
lines.push(
`OAuth Client ID: ${input.oauthClientId}`,
`OAuth Client Secret: ${input.oauthClientSecret}`,
);
} else {
lines.push(
`OAuth Client ID: (leave blank, registers automatically)`,
`OAuth Client Secret: (leave blank, registers automatically)`,
);
}
return { return {
label: 'claude_desktop_config.json', label: 'Claude Desktop · Custom Connector',
code: JSON.stringify( code: lines.join('\n'),
{ note: hasCreds
mcpServers: { ? 'Settings → Connectors → Add custom connector. Paste each value into the matching field. OAuth handshake runs on first use.'
[key]: { : 'Settings → Connectors → Add custom connector. Paste Name + URL, leave the OAuth fields blank. If your network blocks Dynamic Client Registration, generate a fixed Client ID / Secret in the server settings and paste them too.',
url: mcpUrl,
auth: 'oauth2',
},
},
},
null,
2,
),
note: '~/Library/Application Support/Claude/claude_desktop_config.json (macOS) — Settings → Developer (Windows). Restart Claude after saving.',
}; };
}
case 'claude-code':
return {
label: 'Claude Code (CLI)',
code: `claude mcp add ${key} ${mcpUrl} --transport http`,
note: `Run in any project. Use \`claude mcp list\` to confirm and \`claude mcp remove ${key}\` to undo.`,
};
case 'cursor': case 'cursor':
return { return {
label: '.cursor/mcp.json', label: '.cursor/mcp.json',
@ -42,13 +82,62 @@ export function buildSnippet(target: InstallTarget, input: SnippetInput): { labe
null, null,
2, 2,
), ),
note: 'Place at the project root or in ~/.cursor/mcp.json for global use.', note: 'Place at the project root or in ~/.cursor/mcp.json for global use. Restart Cursor after saving.',
}; };
case 'chatgpt':
case 'vscode':
return { return {
label: 'ChatGPT — Custom Connector', label: '.vscode/mcp.json',
code: `Name: ${input.name}\nURL: ${mcpUrl}\nAuth: OAuth 2.1 (Dynamic Client Registration)`, code: JSON.stringify(
note: 'ChatGPT → Settings → Connectors → Add custom connector. Paste the values above. OAuth handshake runs on first use.', {
servers: {
[key]: {
type: 'http',
url: mcpUrl,
},
},
},
null,
2,
),
note: 'VS Code → Copilot Chat → MCP servers. Save the file at the workspace root, then reload the window. OAuth runs on first call.',
};
case 'chatgpt': {
const lines = [
`Name: ${input.name}`,
`URL: ${mcpUrl}`,
`Auth: OAuth 2.1`,
];
if (hasCreds) {
lines.push(
`Client ID: ${input.oauthClientId}`,
`Client Secret: ${input.oauthClientSecret}`,
);
}
return {
label: 'ChatGPT · Custom Connector',
code: lines.join('\n'),
note: hasCreds
? 'ChatGPT → Settings → Connectors → Add custom connector. Paste the values above.'
: 'ChatGPT → Settings → Connectors → Add custom connector. Paste Name + URL. OAuth handshake registers a client automatically on first use.',
};
}
case 'codex':
return {
label: '~/.codex/config.toml',
code: `[mcp_servers.${key}]
type = "http"
url = "${mcpUrl}"`,
note: 'Append to your ~/.codex/config.toml. Restart the Codex CLI. OAuth runs on first tool call.',
};
case 'raw-url':
return {
label: 'Server URL',
code: mcpUrl,
note: 'Any MCP-compatible client. Add it as a "Remote MCP server" (HTTP transport) and approve OAuth on first use.',
}; };
} }
} }

View File

@ -0,0 +1,8 @@
-- Allow generic RFC 7591 Dynamic Client Registration:
-- a client may register without binding to a specific MCP server.
-- /oauth/authorize still enforces the org-ownership check on every
-- authorization, and the access-token `aud` claim is pinned to the
-- resource declared at /token, so a generic client cannot mint a
-- token usable against a server outside the user's org.
ALTER TABLE oauth_clients
ALTER COLUMN server_id DROP NOT NULL;

View File

@ -260,9 +260,13 @@ export const secrets = pgTable('secrets', {
export const oauthClients = pgTable('oauth_clients', { export const oauthClients = pgTable('oauth_clients', {
id: uuid('id').defaultRandom().primaryKey(), id: uuid('id').defaultRandom().primaryKey(),
serverId: uuid('server_id') // Nullable: RFC 7591 Dynamic Client Registration treats the `resource`
.references(() => mcpServers.id, { onDelete: 'cascade' }) // claim as optional, so a client may register generically and only bind
.notNull(), // to a specific server at /oauth/authorize. /authorize enforces the
// org-ownership check on every authorization either way.
serverId: uuid('server_id').references(() => mcpServers.id, {
onDelete: 'cascade',
}),
clientId: varchar('client_id', { length: 128 }).notNull().unique(), clientId: varchar('client_id', { length: 128 }).notNull().unique(),
clientSecretHash: text('client_secret_hash'), clientSecretHash: text('client_secret_hash'),
redirectUris: jsonb('redirect_uris').notNull(), redirectUris: jsonb('redirect_uris').notNull(),

View File

@ -175,5 +175,13 @@ export type IterateServerInput = z.infer<typeof IterateServerInput>;
// ---- Install snippets ---- // ---- Install snippets ----
export const InstallTarget = z.enum(['claude-desktop', 'cursor', 'chatgpt']); export const InstallTarget = z.enum([
'claude-desktop',
'claude-code',
'cursor',
'vscode',
'chatgpt',
'codex',
'raw-url',
]);
export type InstallTarget = z.infer<typeof InstallTarget>; export type InstallTarget = z.infer<typeof InstallTarget>;