fix(oauth): allow generic RFC 7591 DCR + expand install snippets
All checks were successful
Deploy to Production / deploy (push) Successful in 1m28s
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:
parent
e75f9ad4fe
commit
3a05766f88
@ -103,13 +103,20 @@ export async function oauthRoutes(app: FastifyInstance): Promise<void> {
|
||||
const parsed = Body.safeParse(req.body);
|
||||
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;
|
||||
if (parsed.data.resource) {
|
||||
const server = await resolveServerByResource(parsed.data.resource);
|
||||
if (!server) return reply.code(400).send({ error: 'invalid_resource' });
|
||||
serverId = server.id;
|
||||
} else {
|
||||
return reply.code(400).send({ error: 'resource_required' });
|
||||
}
|
||||
|
||||
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' });
|
||||
|
||||
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' });
|
||||
}
|
||||
if (server.orgId !== user.orgId) {
|
||||
|
||||
@ -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: '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: '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: '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 }[] = [
|
||||
{
|
||||
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',
|
||||
@ -98,7 +98,7 @@ const MARKETPLACE_POINTS: { t: string; d: string }[] = [
|
||||
},
|
||||
{
|
||||
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="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]">
|
||||
v0.1 — updated 2026-05-20
|
||||
v0.1 · updated 2026-05-20
|
||||
</span>
|
||||
<h1 className="mt-6 text-balance text-[30px] font-semibold leading-[1.06] tracking-tight sm:text-[40px] md:text-[52px]">
|
||||
Describe your tool.
|
||||
@ -337,7 +337,7 @@ export default function Landing() {
|
||||
</h2>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@ -30,7 +30,7 @@ const COLORS = {
|
||||
|
||||
const SNIPPETS: ReadonlyArray<ReadonlyArray<string>> = [
|
||||
[
|
||||
'// MCP server — auto-generated',
|
||||
'// MCP server (auto-generated)',
|
||||
'import { Server } from "@bmm/mcp";',
|
||||
'',
|
||||
'const server = new Server({',
|
||||
|
||||
@ -85,7 +85,12 @@ export function HeroVideo() {
|
||||
const next = !v.muted;
|
||||
v.muted = 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);
|
||||
}
|
||||
}, []);
|
||||
@ -155,7 +160,7 @@ export function HeroVideo() {
|
||||
color: 'var(--color-fg-muted)',
|
||||
}}
|
||||
>
|
||||
your browser blocked playback — open the video directly
|
||||
your browser blocked playback. open the video directly
|
||||
<ExternalLink size={12} />
|
||||
</a>
|
||||
)}
|
||||
|
||||
@ -8,8 +8,12 @@ import type { InstallTarget } from '@bmm/types';
|
||||
|
||||
const TABS: { id: InstallTarget; label: string }[] = [
|
||||
{ id: 'claude-desktop', label: 'Claude Desktop' },
|
||||
{ id: 'claude-code', label: 'Claude Code' },
|
||||
{ id: 'cursor', label: 'Cursor' },
|
||||
{ id: 'vscode', label: 'VS Code' },
|
||||
{ id: 'chatgpt', label: 'ChatGPT' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'raw-url', label: 'Other' },
|
||||
];
|
||||
|
||||
export function InstallSnippets({ input }: { input: SnippetInput }) {
|
||||
@ -17,7 +21,7 @@ export function InstallSnippets({ input }: { input: SnippetInput }) {
|
||||
const snippet = buildSnippet(tab, input);
|
||||
return (
|
||||
<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) => (
|
||||
<button
|
||||
key={t.id}
|
||||
|
||||
@ -4,29 +4,69 @@ export interface SnippetInput {
|
||||
name: string;
|
||||
slug: 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 mcpUrl = `${input.publicUrl.replace(/\/$/, '')}/mcp`;
|
||||
const baseUrl = stripTrailingSlash(input.publicUrl);
|
||||
const mcpUrl = `${baseUrl}/mcp`;
|
||||
const hasCreds = !!(input.oauthClientId && input.oauthClientSecret);
|
||||
|
||||
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 {
|
||||
label: 'claude_desktop_config.json',
|
||||
code: JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[key]: {
|
||||
url: mcpUrl,
|
||||
auth: 'oauth2',
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
note: '~/Library/Application Support/Claude/claude_desktop_config.json (macOS) — Settings → Developer (Windows). Restart Claude after saving.',
|
||||
label: 'Claude Desktop · Custom Connector',
|
||||
code: lines.join('\n'),
|
||||
note: hasCreds
|
||||
? 'Settings → Connectors → Add custom connector. Paste each value into the matching field. OAuth handshake runs on first use.'
|
||||
: '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.',
|
||||
};
|
||||
}
|
||||
|
||||
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':
|
||||
return {
|
||||
label: '.cursor/mcp.json',
|
||||
@ -42,13 +82,62 @@ export function buildSnippet(target: InstallTarget, input: SnippetInput): { labe
|
||||
null,
|
||||
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 {
|
||||
label: 'ChatGPT — Custom Connector',
|
||||
code: `Name: ${input.name}\nURL: ${mcpUrl}\nAuth: OAuth 2.1 (Dynamic Client Registration)`,
|
||||
note: 'ChatGPT → Settings → Connectors → Add custom connector. Paste the values above. OAuth handshake runs on first use.',
|
||||
label: '.vscode/mcp.json',
|
||||
code: JSON.stringify(
|
||||
{
|
||||
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.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
@ -260,9 +260,13 @@ export const secrets = pgTable('secrets', {
|
||||
|
||||
export const oauthClients = pgTable('oauth_clients', {
|
||||
id: uuid('id').defaultRandom().primaryKey(),
|
||||
serverId: uuid('server_id')
|
||||
.references(() => mcpServers.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
// Nullable: RFC 7591 Dynamic Client Registration treats the `resource`
|
||||
// claim as optional, so a client may register generically and only bind
|
||||
// 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(),
|
||||
clientSecretHash: text('client_secret_hash'),
|
||||
redirectUris: jsonb('redirect_uris').notNull(),
|
||||
|
||||
@ -175,5 +175,13 @@ export type IterateServerInput = z.infer<typeof IterateServerInput>;
|
||||
|
||||
// ---- 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>;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user