fix(supabase): remove non-functional SQL introspection, harden storage encoding, add missing endpoints (#5371)

* fix(supabase): remove non-functional SQL introspection path, harden storage URL encoding, add missing storage endpoints

- introspect.ts no longer attempts raw SQL via a nonexistent PostgREST
  RPC endpoint (always failed); now uses the OpenAPI-spec path directly
  with honest heuristic/best-effort documentation for PK/FK/index fields
- storage tools now trim + URL-encode bucket/path segments before
  building request URLs (download, list, get_public_url,
  create_signed_url, delete_bucket, delete, and the upload API route)
- storage_create_signed_url guards against a missing signedURL field
  in the response instead of silently building a broken URL
- add supabase_storage_create_signed_upload_url, supabase_storage_update_bucket,
  and supabase_storage_empty_bucket tools + block wiring
- change insert/upsert `data` param type from 'array' to 'json' to match
  actual accepted shapes (array or single object)
- bump tool versions to semver (1.0 -> 1.0.0)
- rewrite a BlockMeta template that implied unsupported Supabase Auth
  Admin user-provisioning

(cherry picked from commit 52b655acf59bace806c75c06b4b2cfaebe4b6781)

* fix(supabase): address review feedback on update-bucket, signed upload url, and introspect

- storage_update_bucket now fetches the bucket's current config first
  and only overrides isPublic/fileSizeLimit/allowedMimeTypes when the
  caller explicitly provides them, instead of silently forcing
  public: false on every update (the Storage API's PUT is a full
  replace, not a patch)
- storage_create_signed_upload_url and storage_empty_bucket now check
  response.ok before surfacing an error, so a non-2xx response reports
  Supabase's actual error instead of a misleading parse-side message
- introspect.ts drops the spurious ?select=* query param from the
  OpenAPI spec request (meaningless on the root spec endpoint)

(cherry picked from commit 557e4074594464262b2f631ca66272bf60f027f8)

* fix(supabase): tri-state bucket visibility on update, empty-string param coercion, introspect schema header

- introspect.ts now sends Accept-Profile when a schema param is given,
  matching the convention used by the other DB tools, so schema-scoped
  introspection actually reads from that schema's spec
- storage_update_bucket.ts treats an empty-string param the same as
  "not provided" (e.g. an untouched file size limit input no longer
  coerces to 0)
- the shared "Public Bucket" dropdown always sent an explicit true/false
  for storage_update_bucket (its default masked "not touched"), which
  could still flip a public bucket private; added a dedicated
  "Keep Current / True / False" control for the update operation so
  omitting a choice genuinely omits the isPublic override

(cherry picked from commit c80567b980e9a547b892a222cebee2bd03d89420)

* fix(supabase): check response.ok before parsing storage_create_signed_url

Matches the pattern already applied to the new signed-upload-url tool
this session — a non-2xx response now surfaces Supabase's actual error
message instead of the generic "did not return a signed URL path" one.

(cherry picked from commit 9f40427dd80ec21b4af1e85c9c8218fd961d6931)

* fix(supabase): correct download param semantics, echoed path field, and schema hint mismatch

Found by a second, per-tool parallel validation pass against live Supabase/PostgREST docs and source:

- storage_get_public_url.ts and storage_create_signed_url.ts sent
  download=true literally, which Supabase's Storage API treats as a
  filename override (renaming the downloaded file to "true") rather
  than a boolean flag; forcing a download while keeping the original
  filename requires an empty download= value
- storage_create_signed_url.ts also sent download in the POST body,
  which the sign endpoint ignores entirely — forcing download only
  works as a query param on the returned URL
- storage_create_signed_upload_url.ts read a `path` field from the API
  response that doesn't exist there; it now echoes the caller-supplied
  path, matching the official storage-js client
- introspect.ts's nullable heuristic didn't disclose that a NOT NULL
  column with a default is misreported as nullable (PostgREST omits it
  from the OpenAPI required list in that case); documented alongside
  the other already-disclosed heuristics, and removed a tautological
  schema-filter check left over from an earlier revision
- insert.ts/upsert.ts's `data` param reverted from 'json' back to
  'array': the 'json' type mapped to an LLM-facing JSON-schema type of
  'object', which contradicted the param's own description ("array of
  objects or a single object")
This commit is contained in:
Waleed
2026-07-02 10:45:53 -07:00
committed by GitHub
parent 943ee27686
commit acece910b4
32 changed files with 670 additions and 500 deletions
@@ -11,6 +11,7 @@ import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils'
import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server'
import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response'
import { assertToolFileAccess } from '@/app/api/files/authorization'
import { encodeStoragePath, encodeStorageSegment } from '@/tools/supabase/utils'
export const dynamic = 'force-dynamic'
@@ -185,7 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
return NextResponse.json({ success: false, error: projectValidation.error }, { status: 400 })
}
const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}`
const encodedBucket = encodeStorageSegment(validatedData.bucket)
const encodedPath = encodeStoragePath(fullPath)
const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${encodedBucket}/${encodedPath}`
const headers: Record<string, string> = {
apikey: validatedData.apiKey,
@@ -248,7 +251,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
path: fullPath,
})
const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}`
const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${encodedBucket}/${encodedPath}`
return NextResponse.json({
success: true,
+78 -5
View File
@@ -45,7 +45,10 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
{ label: 'Storage: Copy File', id: 'storage_copy' },
{ label: 'Storage: Get Public URL', id: 'storage_get_public_url' },
{ label: 'Storage: Create Signed URL', id: 'storage_create_signed_url' },
{ label: 'Storage: Create Signed Upload URL', id: 'storage_create_signed_upload_url' },
{ label: 'Storage: Create Bucket', id: 'storage_create_bucket' },
{ label: 'Storage: Update Bucket', id: 'storage_update_bucket' },
{ label: 'Storage: Empty Bucket', id: 'storage_empty_bucket' },
{ label: 'Storage: List Buckets', id: 'storage_list_buckets' },
{ label: 'Storage: Delete Bucket', id: 'storage_delete_bucket' },
],
@@ -686,9 +689,12 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
'storage_move',
'storage_copy',
'storage_create_bucket',
'storage_update_bucket',
'storage_empty_bucket',
'storage_delete_bucket',
'storage_get_public_url',
'storage_create_signed_url',
'storage_create_signed_upload_url',
],
},
required: true,
@@ -919,19 +925,53 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
value: () => 'false',
condition: { field: 'operation', value: 'storage_create_bucket' },
},
{
id: 'updateIsPublic',
title: 'Public Bucket',
type: 'dropdown',
options: [
{ label: 'Keep Current', id: '' },
{ label: 'False (Private)', id: 'false' },
{ label: 'True (Public)', id: 'true' },
],
value: () => '',
condition: { field: 'operation', value: 'storage_update_bucket' },
},
{
id: 'fileSizeLimit',
title: 'File Size Limit (bytes)',
type: 'short-input',
placeholder: '52428800',
condition: { field: 'operation', value: 'storage_create_bucket' },
condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] },
mode: 'advanced',
},
{
id: 'allowedMimeTypes',
title: 'Allowed MIME Types (JSON array)',
type: 'code',
placeholder: '["image/png", "image/jpeg"]',
condition: { field: 'operation', value: 'storage_create_bucket' },
condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] },
mode: 'advanced',
},
{
id: 'path',
title: 'Destination Path',
type: 'short-input',
placeholder: 'folder/file.jpg',
condition: { field: 'operation', value: 'storage_create_signed_upload_url' },
required: true,
},
{
id: 'upsert',
title: 'Allow Overwrite',
type: 'dropdown',
options: [
{ label: 'False', id: 'false' },
{ label: 'True', id: 'true' },
],
value: () => 'false',
condition: { field: 'operation', value: 'storage_create_signed_upload_url' },
mode: 'advanced',
},
],
tools: {
@@ -955,10 +995,13 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
'supabase_storage_move',
'supabase_storage_copy',
'supabase_storage_create_bucket',
'supabase_storage_update_bucket',
'supabase_storage_empty_bucket',
'supabase_storage_list_buckets',
'supabase_storage_delete_bucket',
'supabase_storage_get_public_url',
'supabase_storage_create_signed_url',
'supabase_storage_create_signed_upload_url',
],
config: {
tool: (params) => {
@@ -1001,6 +1044,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
return 'supabase_storage_copy'
case 'storage_create_bucket':
return 'supabase_storage_create_bucket'
case 'storage_update_bucket':
return 'supabase_storage_update_bucket'
case 'storage_empty_bucket':
return 'supabase_storage_empty_bucket'
case 'storage_list_buckets':
return 'supabase_storage_list_buckets'
case 'storage_delete_bucket':
@@ -1009,6 +1056,8 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
return 'supabase_storage_get_public_url'
case 'storage_create_signed_url':
return 'supabase_storage_create_signed_url'
case 'storage_create_signed_upload_url':
return 'supabase_storage_create_signed_upload_url'
default:
throw new Error(`Invalid Supabase operation: ${params.operation}`)
}
@@ -1028,6 +1077,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
functionBody,
functionHeaders,
method,
updateIsPublic,
...rest
} = params
@@ -1200,6 +1250,16 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
result.isPublic = parsedIsPublic
}
// "Keep Current" (empty string) means the caller didn't choose to
// override visibility — omit `isPublic` entirely so the update
// tool preserves the bucket's existing public/private setting
// instead of defaulting to false.
if (operation === 'storage_update_bucket' && updateIsPublic !== undefined) {
if (updateIsPublic === 'true' || updateIsPublic === 'false') {
result.isPublic = updateIsPublic === 'true'
}
}
if (normalizedFileData !== undefined) {
result.fileData = normalizedFileData
}
@@ -1254,6 +1314,11 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
search: { type: 'string', description: 'Search term for filtering' },
expiresIn: { type: 'number', description: 'Expiration time in seconds for signed URL' },
isPublic: { type: 'boolean', description: 'Whether bucket should be public' },
updateIsPublic: {
type: 'string',
description:
'Visibility override for bucket update: "" keeps the current value, "true"/"false" overrides it',
},
fileSizeLimit: { type: 'number', description: 'Maximum file size in bytes' },
allowedMimeTypes: { type: 'array', description: 'Array of allowed MIME types' },
},
@@ -1281,7 +1346,15 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
},
signedUrl: {
type: 'string',
description: 'Temporary signed URL for storage file',
description: 'Temporary signed URL for storage file (download or upload)',
},
token: {
type: 'string',
description: 'Upload token embedded in the signed upload URL',
},
path: {
type: 'string',
description: 'Destination object path for the signed upload URL',
},
tables: {
type: 'json',
@@ -1300,9 +1373,9 @@ export const SupabaseBlockMeta = {
templates: [
{
icon: SupabaseIcon,
title: 'Supabase user provisioning',
title: 'Supabase customer record sync',
prompt:
'Build a workflow that listens for Stripe new-customer events, provisions a Supabase user with the correct role and metadata, and emails the welcome login link.',
'Build a workflow that listens for Stripe new-customer events and upserts a row into a Supabase customers table with the correct plan and metadata, then emails a welcome message.',
modules: ['agent', 'workflows'],
category: 'operations',
tags: ['enterprise', 'automation'],
+6
View File
@@ -3707,14 +3707,17 @@ import {
supabaseRpcTool,
supabaseStorageCopyTool,
supabaseStorageCreateBucketTool,
supabaseStorageCreateSignedUploadUrlTool,
supabaseStorageCreateSignedUrlTool,
supabaseStorageDeleteBucketTool,
supabaseStorageDeleteTool,
supabaseStorageDownloadTool,
supabaseStorageEmptyBucketTool,
supabaseStorageGetPublicUrlTool,
supabaseStorageListBucketsTool,
supabaseStorageListTool,
supabaseStorageMoveTool,
supabaseStorageUpdateBucketTool,
supabaseStorageUploadTool,
supabaseTextSearchTool,
supabaseUpdateTool,
@@ -5218,10 +5221,13 @@ export const tools: Record<string, ToolConfig> = {
supabase_storage_move: supabaseStorageMoveTool,
supabase_storage_copy: supabaseStorageCopyTool,
supabase_storage_create_bucket: supabaseStorageCreateBucketTool,
supabase_storage_update_bucket: supabaseStorageUpdateBucketTool,
supabase_storage_empty_bucket: supabaseStorageEmptyBucketTool,
supabase_storage_list_buckets: supabaseStorageListBucketsTool,
supabase_storage_delete_bucket: supabaseStorageDeleteBucketTool,
supabase_storage_get_public_url: supabaseStorageGetPublicUrlTool,
supabase_storage_create_signed_url: supabaseStorageCreateSignedUrlTool,
supabase_storage_create_signed_upload_url: supabaseStorageCreateSignedUploadUrlTool,
tailscale_list_devices: tailscaleListDevicesTool,
tailscale_get_device: tailscaleGetDeviceTool,
tailscale_delete_device: tailscaleDeleteDeviceTool,
+1 -1
View File
@@ -7,7 +7,7 @@ export const countTool: ToolConfig<SupabaseCountParams, SupabaseCountResponse> =
id: 'supabase_count',
name: 'Supabase Count',
description: 'Count rows in a Supabase table',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const deleteTool: ToolConfig<SupabaseDeleteParams, SupabaseDeleteResponse
id: 'supabase_delete',
name: 'Supabase Delete Row',
description: 'Delete rows from a Supabase table based on filter criteria',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
id: 'supabase_get_row',
name: 'Supabase Get Row',
description: 'Get a single row from a Supabase table based on filter criteria',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+6
View File
@@ -8,14 +8,17 @@ import { queryTool } from '@/tools/supabase/query'
import { rpcTool } from '@/tools/supabase/rpc'
import { storageCopyTool } from '@/tools/supabase/storage_copy'
import { storageCreateBucketTool } from '@/tools/supabase/storage_create_bucket'
import { storageCreateSignedUploadUrlTool } from '@/tools/supabase/storage_create_signed_upload_url'
import { storageCreateSignedUrlTool } from '@/tools/supabase/storage_create_signed_url'
import { storageDeleteTool } from '@/tools/supabase/storage_delete'
import { storageDeleteBucketTool } from '@/tools/supabase/storage_delete_bucket'
import { storageDownloadTool } from '@/tools/supabase/storage_download'
import { storageEmptyBucketTool } from '@/tools/supabase/storage_empty_bucket'
import { storageGetPublicUrlTool } from '@/tools/supabase/storage_get_public_url'
import { storageListTool } from '@/tools/supabase/storage_list'
import { storageListBucketsTool } from '@/tools/supabase/storage_list_buckets'
import { storageMoveTool } from '@/tools/supabase/storage_move'
import { storageUpdateBucketTool } from '@/tools/supabase/storage_update_bucket'
import { storageUploadTool } from '@/tools/supabase/storage_upload'
import { textSearchTool } from '@/tools/supabase/text_search'
import { updateTool } from '@/tools/supabase/update'
@@ -45,3 +48,6 @@ export const supabaseStorageListBucketsTool = storageListBucketsTool
export const supabaseStorageDeleteBucketTool = storageDeleteBucketTool
export const supabaseStorageGetPublicUrlTool = storageGetPublicUrlTool
export const supabaseStorageCreateSignedUrlTool = storageCreateSignedUrlTool
export const supabaseStorageCreateSignedUploadUrlTool = storageCreateSignedUploadUrlTool
export const supabaseStorageUpdateBucketTool = storageUpdateBucketTool
export const supabaseStorageEmptyBucketTool = storageEmptyBucketTool
+1 -1
View File
@@ -7,7 +7,7 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
id: 'supabase_insert',
name: 'Supabase Insert',
description: 'Insert data into a Supabase table',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+49 -434
View File
@@ -1,5 +1,3 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import {
INTROSPECT_TABLE_OUTPUT_PROPERTIES,
type SupabaseColumnSchema,
@@ -10,308 +8,27 @@ import {
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SupabaseIntrospect')
/**
* SQL query to introspect database schema
* This query retrieves all tables, columns, primary keys, foreign keys, and indexes
*/
const INTROSPECTION_SQL = `
WITH table_info AS (
SELECT
t.table_schema,
t.table_name
FROM information_schema.tables t
WHERE t.table_type = 'BASE TABLE'
AND t.table_schema NOT IN ('pg_catalog', 'information_schema', 'auth', 'storage', 'realtime', 'supabase_functions', 'supabase_migrations', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'pgsodium_masks', 'vault', 'pgbouncer', '_timescaledb_internal', '_timescaledb_config', '_timescaledb_catalog', '_timescaledb_cache')
),
columns_info AS (
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.ordinal_position
FROM information_schema.columns c
INNER JOIN table_info t ON c.table_schema = t.table_schema AND c.table_name = t.table_name
),
pk_info AS (
SELECT
tc.table_schema,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
),
fk_info AS (
SELECT
tc.table_schema,
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
),
index_info AS (
SELECT
schemaname AS table_schema,
tablename AS table_name,
indexname AS index_name,
CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END AS is_unique,
indexdef
FROM pg_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema', 'auth', 'storage', 'realtime', 'supabase_functions', 'supabase_migrations', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'pgsodium_masks', 'vault', 'pgbouncer', '_timescaledb_internal', '_timescaledb_config', '_timescaledb_catalog', '_timescaledb_cache')
)
SELECT json_build_object(
'tables', (
SELECT json_agg(
json_build_object(
'schema', t.table_schema,
'name', t.table_name,
'columns', (
SELECT json_agg(
json_build_object(
'name', c.column_name,
'type', c.data_type,
'nullable', c.is_nullable = 'YES',
'default', c.column_default,
'isPrimaryKey', EXISTS (
SELECT 1 FROM pk_info pk
WHERE pk.table_schema = c.table_schema
AND pk.table_name = c.table_name
AND pk.column_name = c.column_name
),
'isForeignKey', EXISTS (
SELECT 1 FROM fk_info fk
WHERE fk.table_schema = c.table_schema
AND fk.table_name = c.table_name
AND fk.column_name = c.column_name
),
'references', (
SELECT json_build_object('table', fk.foreign_table_name, 'column', fk.foreign_column_name)
FROM fk_info fk
WHERE fk.table_schema = c.table_schema
AND fk.table_name = c.table_name
AND fk.column_name = c.column_name
LIMIT 1
)
)
ORDER BY c.ordinal_position
)
FROM columns_info c
WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name
),
'primaryKey', (
SELECT COALESCE(json_agg(pk.column_name), '[]'::json)
FROM pk_info pk
WHERE pk.table_schema = t.table_schema AND pk.table_name = t.table_name
),
'foreignKeys', (
SELECT COALESCE(json_agg(
json_build_object(
'column', fk.column_name,
'referencesTable', fk.foreign_table_name,
'referencesColumn', fk.foreign_column_name
)
), '[]'::json)
FROM fk_info fk
WHERE fk.table_schema = t.table_schema AND fk.table_name = t.table_name
),
'indexes', (
SELECT COALESCE(json_agg(
json_build_object(
'name', idx.index_name,
'unique', idx.is_unique,
'definition', idx.indexdef
)
), '[]'::json)
FROM index_info idx
WHERE idx.table_schema = t.table_schema AND idx.table_name = t.table_name
)
)
)
FROM table_info t
),
'schemas', (
SELECT COALESCE(json_agg(DISTINCT table_schema), '[]'::json)
FROM table_info
)
) AS result;
`
/**
* Escapes a value for single-quoted SQL strings by doubling single quotes
*/
function escapeSqlString(value: string): string {
if (!value || value.length > 63) {
throw new Error(`Invalid value: ${value}`)
}
return value.replace(/'/g, "''")
}
/**
* SQL query filtered by specific schema
*/
const getSchemaFilteredSQL = (schema: string) => {
const safeSchema = escapeSqlString(schema)
return `
WITH table_info AS (
SELECT
t.table_schema,
t.table_name
FROM information_schema.tables t
WHERE t.table_type = 'BASE TABLE'
AND t.table_schema = '${safeSchema}'
),
columns_info AS (
SELECT
c.table_schema,
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
c.ordinal_position
FROM information_schema.columns c
INNER JOIN table_info t ON c.table_schema = t.table_schema AND c.table_name = t.table_name
),
pk_info AS (
SELECT
tc.table_schema,
tc.table_name,
kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY'
AND tc.table_schema = '${safeSchema}'
),
fk_info AS (
SELECT
tc.table_schema,
tc.table_name,
kcu.column_name,
ccu.table_name AS foreign_table_name,
ccu.column_name AS foreign_column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name
AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage ccu
ON ccu.constraint_name = tc.constraint_name
WHERE tc.constraint_type = 'FOREIGN KEY'
AND tc.table_schema = '${safeSchema}'
),
index_info AS (
SELECT
schemaname AS table_schema,
tablename AS table_name,
indexname AS index_name,
CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END AS is_unique,
indexdef
FROM pg_indexes
WHERE schemaname = '${safeSchema}'
)
SELECT json_build_object(
'tables', (
SELECT json_agg(
json_build_object(
'schema', t.table_schema,
'name', t.table_name,
'columns', (
SELECT json_agg(
json_build_object(
'name', c.column_name,
'type', c.data_type,
'nullable', c.is_nullable = 'YES',
'default', c.column_default,
'isPrimaryKey', EXISTS (
SELECT 1 FROM pk_info pk
WHERE pk.table_schema = c.table_schema
AND pk.table_name = c.table_name
AND pk.column_name = c.column_name
),
'isForeignKey', EXISTS (
SELECT 1 FROM fk_info fk
WHERE fk.table_schema = c.table_schema
AND fk.table_name = c.table_name
AND fk.column_name = c.column_name
),
'references', (
SELECT json_build_object('table', fk.foreign_table_name, 'column', fk.foreign_column_name)
FROM fk_info fk
WHERE fk.table_schema = c.table_schema
AND fk.table_name = c.table_name
AND fk.column_name = c.column_name
LIMIT 1
)
)
ORDER BY c.ordinal_position
)
FROM columns_info c
WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name
),
'primaryKey', (
SELECT COALESCE(json_agg(pk.column_name), '[]'::json)
FROM pk_info pk
WHERE pk.table_schema = t.table_schema AND pk.table_name = t.table_name
),
'foreignKeys', (
SELECT COALESCE(json_agg(
json_build_object(
'column', fk.column_name,
'referencesTable', fk.foreign_table_name,
'referencesColumn', fk.foreign_column_name
)
), '[]'::json)
FROM fk_info fk
WHERE fk.table_schema = t.table_schema AND fk.table_name = t.table_name
),
'indexes', (
SELECT COALESCE(json_agg(
json_build_object(
'name', idx.index_name,
'unique', idx.is_unique,
'definition', idx.indexdef
)
), '[]'::json)
FROM index_info idx
WHERE idx.table_schema = t.table_schema AND idx.table_name = t.table_name
)
)
)
FROM table_info t
),
'schemas', (
SELECT COALESCE(json_agg(DISTINCT table_schema), '[]'::json)
FROM table_info
)
) AS result;
`
}
/**
* Tool for introspecting Supabase database schema
* Uses raw SQL execution via PostgREST to retrieve table structures
* Tool for introspecting Supabase database schema.
*
* PostgREST (which powers `/rest/v1`) has no generic "run arbitrary SQL"
* endpoint, so schema introspection is derived from the project's
* auto-generated OpenAPI spec (`GET /rest/v1/` with an OpenAPI `Accept`
* header) rather than a live `information_schema` query. Primary-key
* detection is a best-effort naming heuristic (`id` column), and
* foreign-key detection only succeeds if the table owner has added a
* matching `references table.column` SQL comment — the OpenAPI spec does
* not expose constraint metadata directly. Index information is not
* available via this API at all. `nullable` is also best-effort: PostgREST
* only lists a column under `required` when it's NOT NULL *and* has no
* default, so a NOT NULL column with a default is reported as nullable.
*/
export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntrospectResponse> = {
id: 'supabase_introspect',
name: 'Supabase Introspect',
description:
'Introspect Supabase database schema to get table structures, columns, and relationships',
version: '1.0',
'Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)',
version: '1.0.0',
params: {
projectId: {
@@ -336,127 +53,31 @@ export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntros
},
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/`
},
method: 'POST',
url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`,
method: 'GET',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
Accept: 'application/openapi+json',
...(params.schema ? { 'Accept-Profile': params.schema } : {}),
}),
body: () => ({}),
},
directExecution: async (
params: SupabaseIntrospectParams
): Promise<SupabaseIntrospectResponse> => {
const { apiKey, projectId, schema } = params
try {
const sqlQuery = schema ? getSchemaFilteredSQL(schema) : INTROSPECTION_SQL
const baseUrl = supabaseBaseUrl(projectId)
const response = await fetch(`${baseUrl}/rest/v1/rpc/`, {
method: 'POST',
headers: {
apikey: apiKey,
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
Prefer: 'return=representation',
},
body: JSON.stringify({
query: sqlQuery,
}),
})
if (!response.ok) {
const errorText = await response.text()
logger.warn('Direct RPC call failed, attempting alternative approach', {
status: response.status,
})
const pgResponse = await fetch(`${baseUrl}/rest/v1/?select=*`, {
method: 'GET',
headers: {
apikey: apiKey,
Authorization: `Bearer ${apiKey}`,
Accept: 'application/openapi+json',
},
})
if (!pgResponse.ok) {
throw new Error(`Failed to introspect database: ${errorText}`)
}
const openApiSpec = await pgResponse.json()
const tables = parseOpenApiSpec(openApiSpec, schema)
return {
success: true,
output: {
message: `Successfully introspected ${tables.length} table(s) from database schema`,
tables,
schemas: [...new Set(tables.map((t) => t.schema))],
},
}
}
const data = await response.json()
const result = Array.isArray(data) && data.length > 0 ? data[0].result : data.result || data
const tables: SupabaseTableSchema[] = (result.tables || []).map((table: any) => ({
name: table.name,
schema: table.schema,
columns: (table.columns || []).map((col: any) => ({
name: col.name,
type: col.type,
nullable: col.nullable,
default: col.default,
isPrimaryKey: col.isPrimaryKey,
isForeignKey: col.isForeignKey,
references: col.references,
})),
primaryKey: table.primaryKey || [],
foreignKeys: table.foreignKeys || [],
indexes: (table.indexes || []).map((idx: any) => ({
name: idx.name,
columns: parseIndexColumns(idx.definition || ''),
unique: idx.unique,
})),
}))
return {
success: true,
output: {
message: `Successfully introspected ${tables.length} table(s) from database`,
tables,
schemas: result.schemas || [],
},
}
} catch (error) {
logger.error('Supabase introspection failed', { error })
const errorMessage = getErrorMessage(error, 'Unknown error occurred')
return {
success: false,
output: {
message: 'Failed to introspect database schema',
tables: [],
schemas: [],
},
error: errorMessage,
}
transformResponse: async (response: Response, params?: SupabaseIntrospectParams) => {
if (!response.ok) {
const errorText = await response.text()
throw new Error(`Failed to introspect database: ${errorText}`)
}
},
transformResponse: async (response: Response) => {
const data = await response.json()
const openApiSpec = await response.json()
const tables = parseOpenApiSpec(openApiSpec, params?.schema)
return {
success: true,
output: {
message: 'Schema introspection completed',
tables: data.tables || [],
schemas: data.schemas || [],
message: `Successfully introspected ${tables.length} table(s) from database schema`,
tables,
schemas: [...new Set(tables.map((t) => t.schema))],
},
}
},
@@ -476,19 +97,13 @@ export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntros
}
/**
* Parse index definition to extract column names
*/
function parseIndexColumns(indexDef: string): string[] {
const match = indexDef.match(/\(([^)]+)\)/)
if (match) {
return match[1].split(',').map((col) => col.trim().replace(/"/g, ''))
}
return []
}
/**
* Parse OpenAPI spec to extract table schema information
* This is a fallback method when direct SQL execution is not available
* Parse a PostgREST-generated OpenAPI spec into table schemas.
*
* `isPrimaryKey` is a naming heuristic (`id` column) — PostgREST does not
* expose real constraint metadata in the spec. `isForeignKey`/`references`
* only populate when the table owner has added a `references table.column`
* SQL comment on the column. `indexes` is always empty: index definitions
* are not part of the OpenAPI spec.
*/
function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema[] {
const tables: SupabaseTableSchema[] = []
@@ -511,7 +126,7 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema
for (const [colName, colDef] of Object.entries(properties)) {
const col = colDef as any
const isPK = col.description?.includes('primary key') || colName === 'id'
const isPK = colName === 'id'
const fkMatch = col.description?.match(/references\s+(\w+)\.(\w+)/)
const column: SupabaseColumnSchema = {
@@ -539,18 +154,18 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema
columns.push(column)
}
const schemaName = filterSchema || 'public'
if (!filterSchema || schemaName === filterSchema) {
tables.push({
name: tableName,
schema: schemaName,
columns,
primaryKey,
foreignKeys,
indexes: [],
})
}
tables.push({
name: tableName,
// The OpenAPI spec doesn't map tables to schemas, so this can only
// reflect the schema that was actually requested (or "public" when
// introspecting the default schema) — not necessarily the table's
// true schema in a multi-schema database.
schema: filterSchema || 'public',
columns,
primaryKey,
foreignKeys,
indexes: [],
})
}
return tables
+1 -1
View File
@@ -34,7 +34,7 @@ export const invokeFunctionTool: ToolConfig<
id: 'supabase_invoke_function',
name: 'Supabase Invoke Edge Function',
description: 'Invoke a Supabase Edge Function over HTTP',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
id: 'supabase_query',
name: 'Supabase Query',
description: 'Query data from a Supabase table',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const rpcTool: ToolConfig<SupabaseRpcParams, SupabaseRpcResponse> = {
id: 'supabase_rpc',
name: 'Supabase RPC',
description: 'Call a PostgreSQL function in Supabase',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -10,7 +10,7 @@ export const storageCopyTool: ToolConfig<SupabaseStorageCopyParams, SupabaseStor
id: 'supabase_storage_copy',
name: 'Supabase Storage Copy',
description: 'Copy a file within a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -13,7 +13,7 @@ export const storageCreateBucketTool: ToolConfig<
id: 'supabase_storage_create_bucket',
name: 'Supabase Storage Create Bucket',
description: 'Create a new storage bucket in Supabase',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -0,0 +1,118 @@
import type {
SupabaseStorageCreateSignedUploadUrlParams,
SupabaseStorageCreateSignedUploadUrlResponse,
} from '@/tools/supabase/types'
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageCreateSignedUploadUrlTool: ToolConfig<
SupabaseStorageCreateSignedUploadUrlParams,
SupabaseStorageCreateSignedUploadUrlResponse
> = {
id: 'supabase_storage_create_signed_upload_url',
name: 'Supabase Storage Create Signed Upload URL',
description:
'Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket',
version: '1.0.0',
params: {
projectId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
},
bucket: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the storage bucket',
},
path: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The destination path for the uploaded file (e.g., "folder/file.jpg")',
},
upsert: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description: 'If true, allows overwriting an existing file at this path (default: false)',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase service role secret key',
},
},
request: {
url: (params) => {
const bucket = encodeStorageSegment(params.bucket)
const path = encodeStoragePath(params.path)
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/upload/sign/${bucket}/${path}`
},
method: 'POST',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
...(params.upsert ? { 'x-upsert': 'true' } : {}),
}),
body: () => ({}),
},
transformResponse: async (
response: Response,
params?: SupabaseStorageCreateSignedUploadUrlParams
) => {
let data
try {
data = await response.json()
} catch (parseError) {
throw new Error(
`Failed to parse Supabase storage create signed upload URL response: ${parseError}`
)
}
if (!response.ok) {
throw new Error(
`Failed to create signed upload URL: ${data.message || data.error || response.statusText}`
)
}
const relativeUrl = data.url
if (!relativeUrl) {
throw new Error('Supabase did not return a signed upload URL path in its response')
}
if (!params?.projectId) {
throw new Error('projectId is required to construct the signed upload URL')
}
return {
success: true,
output: {
message: 'Successfully created signed upload URL',
signedUrl: `${supabaseBaseUrl(params.projectId)}/storage/v1${relativeUrl}`,
// The API response has no `path` field — it's the caller-supplied
// destination path, echoed back the same way the official
// storage-js client does.
path: params.path,
token: data.token,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
signedUrl: {
type: 'string',
description: 'The temporary signed URL a client can PUT the file to',
},
path: { type: 'string', description: 'The destination object path' },
token: { type: 'string', description: 'The upload token embedded in the signed URL' },
},
}
@@ -2,7 +2,7 @@ import type {
SupabaseStorageCreateSignedUrlParams,
SupabaseStorageCreateSignedUrlResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageCreateSignedUrlTool: ToolConfig<
@@ -12,7 +12,7 @@ export const storageCreateSignedUrlTool: ToolConfig<
id: 'supabase_storage_create_signed_url',
name: 'Supabase Storage Create Signed URL',
description: 'Create a temporary signed URL for a file in a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -55,7 +55,9 @@ export const storageCreateSignedUrlTool: ToolConfig<
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${params.bucket}/${params.path}`
const bucket = encodeStorageSegment(params.bucket)
const path = encodeStoragePath(params.path)
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${bucket}/${path}`
},
method: 'POST',
headers: (params) => ({
@@ -63,17 +65,9 @@ export const storageCreateSignedUrlTool: ToolConfig<
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: (params) => {
const payload: any = {
expiresIn: Number(params.expiresIn),
}
if (params.download !== undefined) {
payload.download = params.download
}
return payload
},
body: (params) => ({
expiresIn: Number(params.expiresIn),
}),
},
transformResponse: async (response: Response, params?: SupabaseStorageCreateSignedUrlParams) => {
@@ -84,11 +78,28 @@ export const storageCreateSignedUrlTool: ToolConfig<
throw new Error(`Failed to parse Supabase storage create signed URL response: ${parseError}`)
}
if (!response.ok) {
throw new Error(
`Failed to create signed URL: ${data.message || data.error || response.statusText}`
)
}
const relativePath = data.signedURL || data.signedUrl
if (!relativePath) {
throw new Error('Supabase did not return a signed URL path in its response')
}
if (!params?.projectId) {
throw new Error('projectId is required to construct the signed URL')
}
const fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}`
let fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}`
// The Storage API ignores a `download` field in the sign request body —
// forcing download is a client-side query param on the resulting URL.
// An empty value preserves the original filename; a non-empty value
// would override it, so a boolean "true" must never be sent literally.
if (params.download) {
fullUrl += fullUrl.includes('?') ? '&download=' : '?download='
}
return {
success: true,
+3 -3
View File
@@ -3,7 +3,7 @@ import {
type SupabaseStorageDeleteParams,
type SupabaseStorageDeleteResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageDeleteTool: ToolConfig<
@@ -13,7 +13,7 @@ export const storageDeleteTool: ToolConfig<
id: 'supabase_storage_delete',
name: 'Supabase Storage Delete',
description: 'Delete files from a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -44,7 +44,7 @@ export const storageDeleteTool: ToolConfig<
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}`
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${encodeStorageSegment(params.bucket)}`
},
method: 'DELETE',
headers: (params) => ({
@@ -1,9 +1,9 @@
import {
STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES,
STORAGE_MESSAGE_OUTPUT_PROPERTIES,
type SupabaseStorageDeleteBucketParams,
type SupabaseStorageDeleteBucketResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageDeleteBucketTool: ToolConfig<
@@ -13,7 +13,7 @@ export const storageDeleteBucketTool: ToolConfig<
id: 'supabase_storage_delete_bucket',
name: 'Supabase Storage Delete Bucket',
description: 'Delete a storage bucket in Supabase',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -38,7 +38,7 @@ export const storageDeleteBucketTool: ToolConfig<
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${params.bucket}`
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`
},
method: 'DELETE',
headers: (params) => ({
@@ -70,7 +70,7 @@ export const storageDeleteBucketTool: ToolConfig<
results: {
type: 'object',
description: 'Delete operation result',
properties: STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES,
properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
},
},
}
+5 -3
View File
@@ -4,7 +4,7 @@ import {
type SupabaseStorageDownloadParams,
type SupabaseStorageDownloadResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
const logger = createLogger('SupabaseStorageDownloadTool')
@@ -16,7 +16,7 @@ export const storageDownloadTool: ToolConfig<
id: 'supabase_storage_download',
name: 'Supabase Storage Download',
description: 'Download a file from a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -53,7 +53,9 @@ export const storageDownloadTool: ToolConfig<
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}/${params.path}`
const bucket = encodeStorageSegment(params.bucket)
const path = encodeStoragePath(params.path)
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${bucket}/${path}`
},
method: 'GET',
headers: (params) => ({
@@ -0,0 +1,86 @@
import {
STORAGE_MESSAGE_OUTPUT_PROPERTIES,
type SupabaseStorageEmptyBucketParams,
type SupabaseStorageEmptyBucketResponse,
} from '@/tools/supabase/types'
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageEmptyBucketTool: ToolConfig<
SupabaseStorageEmptyBucketParams,
SupabaseStorageEmptyBucketResponse
> = {
id: 'supabase_storage_empty_bucket',
name: 'Supabase Storage Empty Bucket',
description:
'Delete all objects inside a Supabase storage bucket without deleting the bucket itself',
version: '1.0.0',
params: {
projectId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
},
bucket: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the bucket to empty',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase service role secret key',
},
},
request: {
url: (params) => {
const bucket = encodeStorageSegment(params.bucket)
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}/empty`
},
method: 'POST',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
body: () => ({}),
},
transformResponse: async (response: Response) => {
let data
try {
data = await response.json()
} catch (parseError) {
throw new Error(`Failed to parse Supabase storage empty bucket response: ${parseError}`)
}
if (!response.ok) {
throw new Error(
`Failed to empty storage bucket: ${data.message || data.error || response.statusText}`
)
}
return {
success: true,
output: {
message: data.message || 'Successfully emptied storage bucket',
results: data,
},
error: undefined,
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
results: {
type: 'object',
description: 'Empty bucket operation result',
properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
},
},
}
@@ -2,7 +2,7 @@ import type {
SupabaseStorageGetPublicUrlParams,
SupabaseStorageGetPublicUrlResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageGetPublicUrlTool: ToolConfig<
@@ -12,7 +12,7 @@ export const storageGetPublicUrlTool: ToolConfig<
id: 'supabase_storage_get_public_url',
name: 'Supabase Storage Get Public URL',
description: 'Get the public URL for a file in a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -48,10 +48,16 @@ export const storageGetPublicUrlTool: ToolConfig<
* its response.
*/
directExecution: async (params: SupabaseStorageGetPublicUrlParams) => {
let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`
const bucket = encodeStorageSegment(params.bucket)
const path = encodeStoragePath(params.path)
let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}`
if (params.download) {
publicUrl += '?download=true'
// Supabase's `download` query param is a filename override, not a
// boolean flag — an empty value forces a download while preserving
// the original filename. Sending the literal string "true" would
// instead rename the downloaded file to "true".
publicUrl += '?download='
}
return {
@@ -65,8 +71,11 @@ export const storageGetPublicUrlTool: ToolConfig<
},
request: {
url: (params) =>
`${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`,
url: (params) => {
const bucket = encodeStorageSegment(params.bucket)
const path = encodeStoragePath(params.path)
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}`
},
method: 'GET',
headers: () => ({}),
},
+3 -3
View File
@@ -3,14 +3,14 @@ import {
type SupabaseStorageListParams,
type SupabaseStorageListResponse,
} from '@/tools/supabase/types'
import { supabaseBaseUrl } from '@/tools/supabase/utils'
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageListTool: ToolConfig<SupabaseStorageListParams, SupabaseStorageListResponse> = {
id: 'supabase_storage_list',
name: 'Supabase Storage List',
description: 'List files in a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -72,7 +72,7 @@ export const storageListTool: ToolConfig<SupabaseStorageListParams, SupabaseStor
request: {
url: (params) => {
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${params.bucket}`
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${encodeStorageSegment(params.bucket)}`
},
method: 'POST',
headers: (params) => ({
@@ -13,7 +13,7 @@ export const storageListBucketsTool: ToolConfig<
id: 'supabase_storage_list_buckets',
name: 'Supabase Storage List Buckets',
description: 'List all storage buckets in Supabase',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -10,7 +10,7 @@ export const storageMoveTool: ToolConfig<SupabaseStorageMoveParams, SupabaseStor
id: 'supabase_storage_move',
name: 'Supabase Storage Move',
description: 'Move a file within a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
@@ -0,0 +1,170 @@
import { getErrorMessage } from '@sim/utils/errors'
import {
STORAGE_MESSAGE_OUTPUT_PROPERTIES,
type SupabaseStorageUpdateBucketParams,
type SupabaseStorageUpdateBucketResponse,
} from '@/tools/supabase/types'
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
import type { ToolConfig } from '@/tools/types'
export const storageUpdateBucketTool: ToolConfig<
SupabaseStorageUpdateBucketParams,
SupabaseStorageUpdateBucketResponse
> = {
id: 'supabase_storage_update_bucket',
name: 'Supabase Storage Update Bucket',
description: 'Update the configuration of an existing Supabase storage bucket',
version: '1.0.0',
params: {
projectId: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
},
bucket: {
type: 'string',
required: true,
visibility: 'user-or-llm',
description: 'The name of the bucket to update',
},
isPublic: {
type: 'boolean',
required: false,
visibility: 'user-or-llm',
description:
'Whether the bucket should be publicly accessible (leave unset to keep the current value)',
},
fileSizeLimit: {
type: 'number',
required: false,
visibility: 'user-or-llm',
description: 'Maximum file size in bytes (leave unset to keep the current value)',
},
allowedMimeTypes: {
type: 'array',
required: false,
visibility: 'user-or-llm',
description:
'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"]) — leave unset to keep the current value',
},
apiKey: {
type: 'string',
required: true,
visibility: 'user-only',
description: 'Your Supabase service role secret key',
},
},
/**
* Unreachable: `directExecution` below always handles this tool because
* the update must first read the bucket's current configuration (the
* Storage API's update-bucket endpoint is a full-replace PUT, not a
* partial patch). Declared only to satisfy `ToolConfig`'s required
* `request` field.
*/
request: {
url: (params) =>
`${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`,
method: 'PUT',
headers: (params) => ({
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}),
},
/**
* The Storage API's update-bucket endpoint is a full-replace PUT
* (`{id, name, public, file_size_limit?, allowed_mime_types?}`), not a
* partial patch. Fetching the bucket's current configuration first lets
* unset params fall back to their existing value instead of silently
* resetting to a default (e.g. flipping a public bucket private just
* because `isPublic` wasn't provided).
*/
directExecution: async (
params: SupabaseStorageUpdateBucketParams
): Promise<SupabaseStorageUpdateBucketResponse> => {
const baseUrl = supabaseBaseUrl(params.projectId)
const bucket = encodeStorageSegment(params.bucket)
const headers = {
apikey: params.apiKey,
Authorization: `Bearer ${params.apiKey}`,
'Content-Type': 'application/json',
}
try {
const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
method: 'GET',
headers,
})
if (!currentResponse.ok) {
const errorText = await currentResponse.text()
throw new Error(`Failed to read current bucket configuration: ${errorText}`)
}
const current = await currentResponse.json()
// Block subBlocks for a shared field can forward an empty string
// (e.g. an untouched short-input) rather than omitting the key
// entirely — treat that the same as "not provided" so it falls
// back to the bucket's current value instead of coercing to 0/false.
const hasValue = (value: unknown): boolean =>
value !== undefined && value !== null && value !== ''
const payload: any = {
id: params.bucket,
name: params.bucket,
public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public),
file_size_limit: hasValue(params.fileSizeLimit)
? Number(params.fileSizeLimit)
: (current.file_size_limit ?? null),
allowed_mime_types: hasValue(params.allowedMimeTypes)
? params.allowedMimeTypes
: (current.allowed_mime_types ?? null),
}
const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, {
method: 'PUT',
headers,
body: JSON.stringify(payload),
})
if (!updateResponse.ok) {
const errorText = await updateResponse.text()
throw new Error(`Failed to update bucket: ${errorText}`)
}
const data = await updateResponse.json()
return {
success: true,
output: {
message: 'Successfully updated storage bucket',
results: data,
},
error: undefined,
}
} catch (error) {
return {
success: false,
output: {
message: 'Failed to update storage bucket',
results: {},
},
error: getErrorMessage(error, 'Unknown error occurred'),
}
}
},
outputs: {
message: { type: 'string', description: 'Operation status message' },
results: {
type: 'object',
description: 'Update operation result',
properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
},
},
}
+1 -1
View File
@@ -12,7 +12,7 @@ export const storageUploadTool: ToolConfig<
id: 'supabase_storage_upload',
name: 'Supabase Storage Upload',
description: 'Upload a file to a Supabase storage bucket',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const textSearchTool: ToolConfig<SupabaseTextSearchParams, SupabaseTextSe
id: 'supabase_text_search',
name: 'Supabase Text Search',
description: 'Perform full-text search on a Supabase table',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+58 -8
View File
@@ -119,10 +119,11 @@ export const STORAGE_COPY_OUTPUT_PROPERTIES = {
} as const satisfies Record<string, OutputProperty>
/**
* Output definition for storage delete bucket response
* Returns a confirmation message
* Output definition for storage bucket operations that only return a
* confirmation message (delete bucket, update bucket, empty bucket)
* @see https://github.com/supabase/storage-js/blob/main/src/packages/StorageBucketApi.ts
*/
export const STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES = {
export const STORAGE_MESSAGE_OUTPUT_PROPERTIES = {
message: { type: 'string', description: 'Operation status message' },
} as const satisfies Record<string, OutputProperty>
@@ -231,13 +232,24 @@ export const INTROSPECT_REFERENCE_OUTPUT_PROPERTIES = {
export const INTROSPECT_COLUMN_OUTPUT_PROPERTIES = {
name: { type: 'string', description: 'Column name' },
type: { type: 'string', description: 'Column data type' },
nullable: { type: 'boolean', description: 'Whether the column allows null values' },
nullable: {
type: 'boolean',
description:
'Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case',
},
default: { type: 'string', description: 'Default value for the column', optional: true },
isPrimaryKey: { type: 'boolean', description: 'Whether the column is a primary key' },
isForeignKey: { type: 'boolean', description: 'Whether the column is a foreign key' },
isPrimaryKey: {
type: 'boolean',
description: 'Best-effort guess based on the column being named "id" (not authoritative)',
},
isForeignKey: {
type: 'boolean',
description:
'True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys',
},
references: {
type: 'object',
description: 'Foreign key reference details',
description: 'Foreign key reference details, when detected via SQL comment',
optional: true,
properties: INTROSPECT_REFERENCE_OUTPUT_PROPERTIES,
},
@@ -294,7 +306,8 @@ export const INTROSPECT_TABLE_OUTPUT_PROPERTIES = {
},
indexes: {
type: 'array',
description: 'Array of index definitions',
description:
'Always empty — index definitions are not exposed by the OpenAPI spec this tool reads',
items: {
type: 'object',
properties: INTROSPECT_INDEX_OUTPUT_PROPERTIES,
@@ -589,6 +602,43 @@ export interface SupabaseStorageCreateSignedUrlResponse extends ToolResponse {
error?: string
}
export interface SupabaseStorageCreateSignedUploadUrlParams {
apiKey: string
projectId: string
bucket: string
path: string
upsert?: boolean
}
export interface SupabaseStorageCreateSignedUploadUrlResponse extends ToolResponse {
output: {
message: string
signedUrl: string
path: string
token: string
}
error?: string
}
export interface SupabaseStorageUpdateBucketParams {
apiKey: string
projectId: string
bucket: string
isPublic?: boolean
fileSizeLimit?: number
allowedMimeTypes?: string[]
}
export interface SupabaseStorageUpdateBucketResponse extends SupabaseBaseResponse {}
export interface SupabaseStorageEmptyBucketParams {
apiKey: string
projectId: string
bucket: string
}
export interface SupabaseStorageEmptyBucketResponse extends SupabaseBaseResponse {}
/**
* Parameters for introspecting a Supabase database schema
*/
+1 -1
View File
@@ -7,7 +7,7 @@ export const updateTool: ToolConfig<SupabaseUpdateParams, SupabaseUpdateResponse
id: 'supabase_update',
name: 'Supabase Update Row',
description: 'Update rows in a Supabase table based on filter criteria',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+1 -1
View File
@@ -7,7 +7,7 @@ export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse
id: 'supabase_upsert',
name: 'Supabase Upsert',
description: 'Insert or update data in a Supabase table (upsert operation)',
version: '1.0',
version: '1.0.0',
params: {
projectId: {
+21
View File
@@ -12,3 +12,24 @@ export function supabaseBaseUrl(projectId: string): string {
}
return `https://${result.sanitized}.supabase.co`
}
/**
* URL-encodes a single storage path segment (bucket name), trimming
* copy-paste whitespace first so the value is safe to interpolate into a URL.
*/
export function encodeStorageSegment(segment: string): string {
return encodeURIComponent(segment.trim())
}
/**
* URL-encodes a storage object path for use inside a URL, preserving `/`
* as a path separator while encoding each segment (and trimming
* copy-paste whitespace) so spaces, `#`, `?`, and other reserved
* characters in file names don't corrupt the request.
*/
export function encodeStoragePath(path: string): string {
return path
.split('/')
.map((segment) => encodeURIComponent(segment.trim()))
.join('/')
}
+1 -1
View File
@@ -13,7 +13,7 @@ export const vectorSearchTool: ToolConfig<
id: 'supabase_vector_search',
name: 'Supabase Vector Search',
description: 'Perform similarity search using pgvector in a Supabase table',
version: '1.0',
version: '1.0.0',
params: {
projectId: {