diff --git a/apps/api/src/routes/oauth.ts b/apps/api/src/routes/oauth.ts index 034e3f4..0331c75 100644 --- a/apps/api/src/routes/oauth.ts +++ b/apps/api/src/routes/oauth.ts @@ -103,13 +103,20 @@ export async function oauthRoutes(app: FastifyInstance): Promise { 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 { 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) { diff --git a/apps/web/app/(marketing)/page.tsx b/apps/web/app/(marketing)/page.tsx index 277f267..6d18c60 100644 --- a/apps/web/app/(marketing)/page.tsx +++ b/apps/web/app/(marketing)/page.tsx @@ -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() {
- v0.1 — updated 2026-05-20 + v0.1 · updated 2026-05-20

Describe your tool. @@ -337,7 +337,7 @@ export default function Landing() {

- Real integrations our customers ship today — built from one prompt each. + Real integrations our customers ship today. Built from one prompt each.

diff --git a/apps/web/components/hero-animation.tsx b/apps/web/components/hero-animation.tsx index 0e9f0bd..b7e8d00 100644 --- a/apps/web/components/hero-animation.tsx +++ b/apps/web/components/hero-animation.tsx @@ -30,7 +30,7 @@ const COLORS = { const SNIPPETS: ReadonlyArray> = [ [ - '// MCP server — auto-generated', + '// MCP server (auto-generated)', 'import { Server } from "@bmm/mcp";', '', 'const server = new Server({', diff --git a/apps/web/components/hero-video.tsx b/apps/web/components/hero-video.tsx index 4b34ee2..378c448 100644 --- a/apps/web/components/hero-video.tsx +++ b/apps/web/components/hero-video.tsx @@ -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 )} diff --git a/apps/web/components/install-snippets.tsx b/apps/web/components/install-snippets.tsx index 6ca28d6..fd7696f 100644 --- a/apps/web/components/install-snippets.tsx +++ b/apps/web/components/install-snippets.tsx @@ -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 (
-
+
{TABS.map((t) => (