Files
supabase/apps/ui-library/components/block-item-code.tsx
T
b04e26872b feat(ui-library): add MCP server block (#49573)
## I have read the
[CONTRIBUTING.md](https://github.com/supabase/supabase/blob/master/CONTRIBUTING.md)
file.

YES

## What kind of change does this PR introduce?

Feature — a new UI Library block. Bottom of a two-PR stack; #49579
builds on it.

## What is the new behavior?

Adds an `mcp-server` block: a Supabase Edge Function that exposes MCP
tools scoped to the signed-in user. It is backend-only, so every file
has an explicit target and no `components.json` is needed.

- `withSupabase({ auth: 'user' })` verifies the access token and gives
each tool an RLS-scoped client. Both product session tokens and OAuth
tokens work; only the latter carry `client_id`.
- `withOAuthProtectedResource` serves RFC 9728 metadata and adds a
`WWW-Authenticate` challenge to `401`s, so external MCP clients can
discover the authorization server.
- Tools are composed in `tools/index.ts`. One is included, `whoami`,
which shows the caller's identity and OAuth client.

Docs at `/library/docs/headless/mcp-server`, under a new MCP group in
the sidebar. `BlockItem` gained a `showOpenInV0` flag (v0 cannot take
Deno functions), and the file-tree viewer now picks a language per file
instead of always TypeScript.

## To test

1. `npx shadcn@latest add
http://localhost:3004/library/r/mcp-server.json` into a Supabase project
or empty directory.
2. Add `[functions.mcp-server] verify_jwt = false` to
`supabase/config.toml`, then:
   ```bash
   supabase start
supabase functions serve mcp-server --env-file supabase/functions/.env
   ```
3. **Unauthenticated:** `curl -i
localhost:54321/functions/v1/mcp-server` returns `401` with a
`WWW-Authenticate` header, and
`/functions/v1/mcp-server/oauth-protected-resource` returns the metadata
document.
4. **Product session:** sign up a user, then call the endpoint with
`Authorization: Bearer <their access token>`. `tools/list` shows
`whoami`; calling it returns that user's id and `client_id: null`.
5. **External client:** enable `[auth.oauth_server]` with
`allow_dynamic_registration = true`, install the OAuth Consent block,
point an MCP client (Claude Code, Codex) at the function URL, approve
the consent screen, and call `whoami` again. `client_id` is now
populated.
6. Confirm RLS holds: add a table with a user-scoped policy and a tool
that reads it, then check a second user cannot see the first user's
rows.
7. Docs page renders at `/library/docs/headless/mcp-server`, and
`deno.json` / `.env.example` in the folder tree highlight as JSON and
bash rather than TypeScript.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

- **New Features**
- Added an installable Supabase MCP Server block with user-scoped
authentication and a read-only identity tool.
  - Added MCP Blocks to documentation navigation and setup guidance.
- Code blocks now automatically detect syntax highlighting from file
names.
  - Added an option to hide the “Open in v0” button.

- **Documentation**
- Expanded MCP Server guidance covering installation, configuration,
validation, deployment, OAuth, and security.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Saxon Fletcher <SaxonF@users.noreply.github.com>
2026-09-02 14:53:38 +10:00

143 lines
4.1 KiB
TypeScript

'use client'
import { File } from 'lucide-react'
import { useState } from 'react'
import { flattenTree, TreeView, TreeViewItem } from 'ui'
import { CodeBlock, type CodeBlockLang } from 'ui-patterns/CodeBlock'
import { RegistryNode } from '@/lib/process-registry'
interface BlockItemCodeProps {
files: RegistryNode[]
}
interface TreeNode {
name: string
children: TreeNode[]
metadata: { path: string }
}
const flattenChildren = (files: RegistryNode[]): TreeNode[] => {
return files.map(
(node): TreeNode => ({
name: node.name,
children: node.children ? flattenChildren(node.children) : [],
metadata: { path: node.path },
})
)
}
const LANGUAGES: Record<string, CodeBlockLang> = {
bash: 'bash',
html: 'html',
js: 'js',
json: 'json',
jsx: 'jsx',
sh: 'bash',
sql: 'sql',
toml: 'toml',
yaml: 'yaml',
yml: 'yaml',
}
const languageFor = (fileName: string | undefined): CodeBlockLang => {
const normalized = fileName?.toLowerCase() ?? ''
if (normalized.startsWith('.env')) return 'bash'
if (normalized === 'deno.lock') return 'json'
return LANGUAGES[normalized.split('.').pop() ?? ''] ?? 'ts'
}
const findFirstFile = (nodes: RegistryNode[]): RegistryNode | null => {
for (const node of nodes) {
if (node.type === 'file') {
return node
}
if (node.children) {
const foundFile = findFirstFile(node.children)
if (foundFile) {
return foundFile
}
}
}
return null
}
export function BlockItemCode({ files }: BlockItemCodeProps) {
// Find the first file to select by default
const [selectedFile, setSelectedFile] = useState<RegistryNode | null>(findFirstFile(files))
const flattenedData = flattenTree({ name: '', children: flattenChildren(files) })
// Handle file selection from the TreeView
const handleNodeSelect = (element: any) => {
const findFileByPath = (nodes: RegistryNode[], path: string): RegistryNode | null => {
for (const node of nodes) {
if (node.path === path) {
return node
}
if (node.children) {
const found = findFileByPath(node.children, path)
if (found) {
return found
}
}
}
return null
}
const filePath = element.metadata.path
const foundFile = findFileByPath(files, filePath)
if (foundFile?.type === 'directory') return
setSelectedFile(foundFile || null)
}
return (
<div className="flex mt-4 border rounded-lg overflow-hidden h-[652px] not-prose">
{/* File browser sidebar */}
<div className="w-64 py-2 border-r bg-muted/30 overflow-y-auto">
<TreeView
data={flattenedData}
aria-label="file browser"
className="w-full"
defaultExpandedIds={flattenedData.filter((n) => n.children?.length).map((n) => n.id)}
defaultSelectedIds={flattenedData
.filter((n) => n.metadata?.path === selectedFile?.path)
.map((n) => n.id)}
onNodeSelect={({ element }) => handleNodeSelect(element)}
nodeRenderer={({ element, isBranch, isExpanded, getNodeProps, level, isSelected }) => (
<TreeViewItem
{...getNodeProps()}
isExpanded={isExpanded}
isBranch={isBranch}
isSelected={isSelected}
level={level}
icon={<File strokeWidth={1.5} size={16} className="shrink-0" />}
name={element.name}
className="gap-1.5"
/>
)}
/>
</div>
{/* Code display area */}
{selectedFile?.content ? (
<CodeBlock
wrapperClassName="w-full"
className="h-full max-w-none w-full! flex-1 font-mono text-xs rounded-none border-none"
language={languageFor(selectedFile.name)}
>
{selectedFile?.content}
</CodeBlock>
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
<div className="flex flex-col items-center gap-2">
<p>No file selected or file content unavailable</p>
</div>
</div>
)}
</div>
)
}