Files
supabase/apps/ui-library/tests/headless-task-tools.test.mts
T
19d7233580 feat(ui-library): add headless app block for TanStack Start (#49579)
## 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>
2026-09-14 10:30:26 +10:00

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()
}
})