mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
## 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. Stacked on #49573 (already in main) Fixes AI-1064 ## What is the new behavior? Adds `headless-app-tanstack`: customers sign in, authorize an MCP client, and use the product through agent tool calls. It composes the existing Password-Based Auth, OAuth Consent, and MCP Server blocks. - `/agents` provides a copyable connection prompt, lists OAuth authorizations, and lets customers revoke access. - The shared MCP runtime exposes `whoami` plus example task CRUD tools. Tools use the caller's Supabase client, with database grants and RLS enforcing ownership. - A root-level `supabase/` directory supplies local Auth/OAuth configuration, a declarative tasks schema, and Edge Function files, including `.env.example`. - Docs cover local setup, signing keys, migrations, environment configuration, deployment, and extending the tools. `/example/headless-app` previews the sign-in, consent, connect, and connected states. Shared block fixes make a fresh install work: - Explicit public URL resolution fixes OAuth discovery in local Edge Runtime when middleware runtime detection fails. Both external OAuth access tokens and ordinary authenticated app session tokens remain supported; embedded agents do not need an additional consent flow. - Registry targets keep backend files outside `src/`, and generated consumer routes omit source-only TypeScript suppressions. - Signup respects `auth.email.enable_confirmations`; sign-in/signup preserve the return destination. Missing consent IDs retain the existing error state without serializing `null` into the URL. ## How to test Use the UI Library on **staging** and follow the block pages' instructions. 1. Open the **Headless App** block page for TanStack Start. Install it into a fresh app and follow the setup instructions through connecting an MCP client. 2. Sign up, open `/agents`, and use the connection prompt to authorize a client. Call `whoami`, then create, list, update, and delete a task. 3. Confirm the client appears on `/agents`. Revoke access and verify it disappears and token refresh fails. An existing access token can continue working until it expires. 4. Follow the **MCP Server** block page's embedded-agent instructions using an authenticated app session. Confirm tools work without another OAuth consent flow and `whoami` returns `client_id: null`. 5. With a second user, confirm each user can only access their own tasks. Check that signup behaves correctly for the configured email-confirmation setting. 6. Check the Headless App preview states and run the installed app's typecheck and production build. ## Validation performed Fresh local installation and browser/SDK verification passed: 26 live MCP/Data API checks, 10 Deno tests, and 7 connection-page component tests. Also passed UI Library typecheck, targeted lint, registry/Markdown builds, and fresh consumer typecheck/production build. Both OAuth and ordinary app session authentication were exercised. Hosted deployment and consuming the confirmation-email link were not tested. <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added a TanStack Headless App example with sign-in, OAuth consent, MCP connection, and connected-agent screens. - Added task management tools for listing, creating, updating, and deleting tasks through MCP. - Added connected-agent management, including server URL and prompt copying, refresh, and access revocation. - Added a new Headless App registry block and documentation. - **Bug Fixes** - Preserved intended destinations through sign-up, email confirmation, and protected-route login redirects. - Improved OAuth discovery URL handling across forwarded-host deployments. - **Documentation** - Updated setup, environment, deployment, and Supabase CLI guidance for headless apps and MCP servers. <!-- 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> Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: repro <repro@local> Co-authored-by: Raúl Barroso <code@raulb.dev>
224 lines
7.9 KiB
TypeScript
224 lines
7.9 KiB
TypeScript
// Copied beside the assembled Edge Function by scripts/test-headless-tools.mts.
|
|
// Real MCP input validation and Supabase queries run against an in-memory HTTP fixture.
|
|
import assert from 'node:assert/strict'
|
|
import { createMcpHandler, McpServer } from 'npm:@modelcontextprotocol/server@2.0.0'
|
|
import { createClient } from 'npm:@supabase/supabase-js@2.108.2'
|
|
|
|
import { registerTools, type ToolContext } from './tools/index.ts'
|
|
|
|
const id = 'ab4cbbf1-f726-4bf4-b852-684f21f470ae'
|
|
const task = { id, title: 'Try task tools', closed: false, created_at: '2026-01-01T00:00:00Z' }
|
|
|
|
function fixture(data: unknown = [], status = 200) {
|
|
const requests: Request[] = []
|
|
const supabase = createClient('http://supabase.test', 'test-publishable-key', {
|
|
auth: { persistSession: false, autoRefreshToken: false, detectSessionInUrl: false },
|
|
global: {
|
|
headers: { Authorization: 'Bearer caller-token' },
|
|
fetch: (input, init) => {
|
|
requests.push(new Request(input, init))
|
|
return Promise.resolve(Response.json(data, { status }))
|
|
},
|
|
},
|
|
})
|
|
const handler = createMcpHandler(() => {
|
|
const server = new McpServer({ name: 'headless-test', version: '1.0.0' })
|
|
registerTools(server, {
|
|
supabase,
|
|
userClaims: { id, role: 'authenticated' } as ToolContext['userClaims'],
|
|
jwtClaims: { sub: id, client_id: 'agent-client' } as ToolContext['jwtClaims'],
|
|
})
|
|
return server
|
|
})
|
|
|
|
async function rpc(method: string, params: Record<string, unknown>) {
|
|
const response = await handler.fetch(
|
|
new Request('http://mcp.test', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
Accept: 'application/json, text/event-stream',
|
|
'MCP-Protocol-Version': '2025-11-25',
|
|
},
|
|
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method, params }),
|
|
})
|
|
)
|
|
const text = await response.text()
|
|
assert.equal(response.status, 200, text)
|
|
const json = response.headers.get('content-type')?.includes('text/event-stream')
|
|
? text
|
|
.split('\n')
|
|
.find((line) => line.startsWith('data: '))!
|
|
.slice(6)
|
|
: text
|
|
const message = JSON.parse(json)
|
|
assert.equal(message.error, undefined, json)
|
|
return message.result
|
|
}
|
|
|
|
return {
|
|
requests,
|
|
rpc,
|
|
call: (name: string, args: Record<string, unknown> = {}) =>
|
|
rpc('tools/call', { name, arguments: args }),
|
|
close: handler.close,
|
|
}
|
|
}
|
|
|
|
Deno.test(
|
|
'assembled server exposes identity and all four task tools with side-effect hints',
|
|
async () => {
|
|
const app = fixture()
|
|
try {
|
|
const { tools } = await app.rpc('tools/list', {})
|
|
assert.deepEqual(tools.map((tool: { name: string }) => tool.name).sort(), [
|
|
'create_task',
|
|
'delete_task',
|
|
'list_tasks',
|
|
'update_task',
|
|
'whoami',
|
|
])
|
|
const byName = Object.fromEntries(tools.map((tool: { name: string }) => [tool.name, tool]))
|
|
assert.equal(byName.list_tasks.annotations.readOnlyHint, true)
|
|
assert.equal(byName.create_task.annotations.idempotentHint, false)
|
|
assert.equal(byName.delete_task.annotations.destructiveHint, true)
|
|
const identity = await app.call('whoami')
|
|
assert.equal(identity.structuredContent.client_id, 'agent-client')
|
|
assert.equal(app.requests.length, 0)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
Deno.test('create trims the title and sends only it through the caller-scoped client', async () => {
|
|
const app = fixture(task)
|
|
try {
|
|
const result = await app.call('create_task', { title: ' Try task tools ' })
|
|
assert.deepEqual(result.structuredContent, { task })
|
|
assert.equal(app.requests.length, 1)
|
|
const [request] = app.requests
|
|
assert.equal(request.method, 'POST')
|
|
assert.equal(request.headers.get('authorization'), 'Bearer caller-token')
|
|
assert.deepEqual(await request.json(), { title: task.title })
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test('invalid inputs fail MCP validation before any database request', async () => {
|
|
const app = fixture()
|
|
try {
|
|
for (const [name, args] of [
|
|
['create_task', { title: ' \t\n ' }],
|
|
['create_task', { title: 'x'.repeat(201) }],
|
|
['create_task', { title: 'Spoof owner', user_id: id }],
|
|
['update_task', { id }],
|
|
['update_task', { id, closed: 'true' }],
|
|
['update_task', { id, closed: false, user_id: id }],
|
|
['delete_task', { id: 'invalid' }],
|
|
['list_tasks', { limit: 101 }],
|
|
['list_tasks', { offset: -1 }],
|
|
] as const) {
|
|
const result = await app.call(name, args)
|
|
assert.equal(result.isError, true, `${name}: ${JSON.stringify(args)}`)
|
|
}
|
|
assert.equal(app.requests.length, 0)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test(
|
|
'list filters open tasks and returns a bounded page with a continuation offset',
|
|
async () => {
|
|
const app = fixture([task, { ...task, id: 'extra-row' }])
|
|
try {
|
|
const result = await app.call('list_tasks', { closed: false, limit: 1, offset: 3 })
|
|
assert.deepEqual(result.structuredContent, { tasks: [task], next_offset: 4 })
|
|
const url = new URL(app.requests[0].url)
|
|
assert.equal(url.pathname, '/rest/v1/tasks')
|
|
assert.equal(url.searchParams.get('closed'), 'eq.false')
|
|
assert.equal(url.searchParams.get('order'), 'created_at.desc,id.desc')
|
|
assert.equal(url.searchParams.get('offset'), '3')
|
|
assert.equal(url.searchParams.get('limit'), '2')
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
}
|
|
)
|
|
|
|
Deno.test('list supplies defaults and an empty final page', async () => {
|
|
const app = fixture([])
|
|
try {
|
|
const result = await app.call('list_tasks')
|
|
assert.deepEqual(result.structuredContent, { tasks: [], next_offset: null })
|
|
const url = new URL(app.requests[0].url)
|
|
assert.equal(url.searchParams.get('limit'), '21')
|
|
assert.equal(url.searchParams.get('offset'), '0')
|
|
assert.equal(url.searchParams.has('closed'), false)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test('update preserves omitted fields and supports reopening a task', async () => {
|
|
const app = fixture([task])
|
|
try {
|
|
const result = await app.call('update_task', { id, closed: false })
|
|
assert.deepEqual(result.structuredContent, { task })
|
|
const [request] = app.requests
|
|
assert.equal(request.method, 'PATCH')
|
|
assert.equal(new URL(request.url).searchParams.get('id'), `eq.${id}`)
|
|
assert.deepEqual(await request.json(), { closed: false })
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test('delete returns the deleted ID and limits deletion to that ID', async () => {
|
|
const app = fixture([{ id }])
|
|
try {
|
|
const result = await app.call('delete_task', { id })
|
|
assert.deepEqual(result.structuredContent, { deleted: true, id })
|
|
assert.equal(app.requests[0].method, 'DELETE')
|
|
assert.equal(new URL(app.requests[0].url).searchParams.get('id'), `eq.${id}`)
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test('mutations report an error when RLS hides the row or it does not exist', async () => {
|
|
const app = fixture([])
|
|
try {
|
|
for (const name of ['update_task', 'delete_task']) {
|
|
const result = await app.call(name, name === 'update_task' ? { id, closed: true } : { id })
|
|
assert.equal(result.isError, true)
|
|
assert.equal(result.content[0].text, 'Task not found or you do not have access.')
|
|
}
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|
|
|
|
Deno.test('database failures retain their actionable message and error code', async () => {
|
|
const app = fixture(
|
|
{ code: '42501', message: 'permission denied for table tasks', hint: null },
|
|
403
|
|
)
|
|
try {
|
|
for (const [name, args] of [
|
|
['list_tasks', {}],
|
|
['create_task', { title: 'Test permissions' }],
|
|
['update_task', { id, closed: true }],
|
|
['delete_task', { id }],
|
|
] as const) {
|
|
const result = await app.call(name, args)
|
|
assert.equal(result.isError, true)
|
|
assert.match(result.content[0].text, /\[42501\] permission denied for table tasks/)
|
|
}
|
|
} finally {
|
|
await app.close()
|
|
}
|
|
})
|