snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s
This commit is contained in:
648
supabase/functions/mcp/index.ts
Normal file
648
supabase/functions/mcp/index.ts
Normal file
@@ -0,0 +1,648 @@
|
||||
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||
import { McpServer } from "npm:@modelcontextprotocol/sdk@1.28.0/server/mcp.js";
|
||||
import { WebStandardStreamableHTTPServerTransport } from "npm:@modelcontextprotocol/sdk@1.28.0/server/webStandardStreamableHttp.js";
|
||||
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "npm:jose@5";
|
||||
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
|
||||
import { z } from "npm:zod@^3.25";
|
||||
import { validateReadOnly, validateWrite } from "./validateSql.ts";
|
||||
import { TASK_LIST_HTML, TASK_LIST_UI_URI } from "./taskListUi.ts";
|
||||
import { corsHeaders } from "../_shared/cors.ts";
|
||||
|
||||
// --- Environment & Config ---
|
||||
|
||||
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
|
||||
const SUPABASE_JWT_ISSUER =
|
||||
Deno.env.get("SB_JWT_ISSUER") ?? `${SUPABASE_URL}/auth/v1`;
|
||||
const CRM_BASE_URL = (Deno.env.get("CRM_BASE_URL") ?? "").replace(/\/$/, "");
|
||||
|
||||
const JWKS = createRemoteJWKSet(
|
||||
new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`),
|
||||
);
|
||||
|
||||
const connectionString =
|
||||
Deno.env.get("SUPABASE_DB_URL") ||
|
||||
"postgresql://postgres:postgres@db:5432/postgres";
|
||||
const pool = new Pool(connectionString, 1);
|
||||
|
||||
// --- URL Helpers ---
|
||||
|
||||
function getBaseUrl(req: Request): string {
|
||||
const forwardedHost = req.headers.get("x-forwarded-host");
|
||||
if (forwardedHost) {
|
||||
// When behind a proxy (ngrok, production), always use HTTPS.
|
||||
// x-forwarded-proto may not survive the Supabase gateway chain.
|
||||
return `https://${forwardedHost}`;
|
||||
}
|
||||
const url = new URL(req.url);
|
||||
const host = url.host;
|
||||
// Supabase edge functions see http:// internally, but are served over HTTPS publicly
|
||||
const proto =
|
||||
host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
|
||||
return `${proto}://${host}`;
|
||||
}
|
||||
|
||||
function getResourceMetadataUrl(req: Request): string {
|
||||
return `${getBaseUrl(req)}/functions/v1/mcp/oauth-protected-resource`;
|
||||
}
|
||||
|
||||
// --- Auth ---
|
||||
|
||||
interface AuthInfo {
|
||||
token: string;
|
||||
userId: string;
|
||||
role?: string;
|
||||
clientId?: string;
|
||||
}
|
||||
|
||||
async function validateToken(req: Request): Promise<AuthInfo | null> {
|
||||
const authHeader = req.headers.get("authorization");
|
||||
if (!authHeader) return null;
|
||||
|
||||
const [bearer, token] = authHeader.split(" ");
|
||||
if (bearer !== "Bearer" || !token) return null;
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, JWKS, {
|
||||
issuer: SUPABASE_JWT_ISSUER,
|
||||
});
|
||||
|
||||
if (!payload.sub) return null;
|
||||
|
||||
return {
|
||||
token,
|
||||
userId: payload.sub,
|
||||
role: payload.role as string | undefined,
|
||||
clientId: payload.client_id as string | undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Database: get_schema ---
|
||||
|
||||
async function getSchemaData(): Promise<string> {
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
// Query 1: All columns from public schema
|
||||
const columnsResult = await client.queryObject<{
|
||||
table_name: string;
|
||||
column_name: string;
|
||||
data_type: string;
|
||||
is_nullable: string;
|
||||
column_default: string | null;
|
||||
table_type: string;
|
||||
}>(`
|
||||
SELECT
|
||||
c.table_name,
|
||||
c.column_name,
|
||||
c.data_type,
|
||||
c.is_nullable,
|
||||
c.column_default,
|
||||
t.table_type
|
||||
FROM information_schema.columns c
|
||||
JOIN information_schema.tables t
|
||||
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
|
||||
WHERE c.table_schema = 'public'
|
||||
ORDER BY c.table_name, c.ordinal_position
|
||||
`);
|
||||
|
||||
// Query 2: Foreign key relationships
|
||||
const fkResult = await client.queryObject<{
|
||||
source_table: string;
|
||||
source_column: string;
|
||||
target_table: string;
|
||||
target_column: string;
|
||||
}>(`
|
||||
SELECT
|
||||
src.relname AS source_table,
|
||||
src_att.attname AS source_column,
|
||||
tgt.relname AS target_table,
|
||||
tgt_att.attname AS target_column
|
||||
FROM pg_catalog.pg_constraint con
|
||||
JOIN pg_catalog.pg_class src ON con.conrelid = src.oid
|
||||
JOIN pg_catalog.pg_namespace nsp ON src.relnamespace = nsp.oid
|
||||
JOIN pg_catalog.pg_class tgt ON con.confrelid = tgt.oid
|
||||
JOIN pg_catalog.pg_attribute src_att
|
||||
ON src_att.attrelid = con.conrelid AND src_att.attnum = ANY(con.conkey)
|
||||
JOIN pg_catalog.pg_attribute tgt_att
|
||||
ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = ANY(con.confkey)
|
||||
WHERE con.contype = 'f' AND nsp.nspname = 'public'
|
||||
ORDER BY src.relname
|
||||
`);
|
||||
|
||||
// Group columns by table
|
||||
const tables = new Map<
|
||||
string,
|
||||
{
|
||||
type: string;
|
||||
columns: {
|
||||
name: string;
|
||||
type: string;
|
||||
nullable: boolean;
|
||||
default: string | null;
|
||||
}[];
|
||||
}
|
||||
>();
|
||||
for (const row of columnsResult.rows) {
|
||||
if (!tables.has(row.table_name)) {
|
||||
tables.set(row.table_name, {
|
||||
type: row.table_type === "VIEW" ? "View" : "Table",
|
||||
columns: [],
|
||||
});
|
||||
}
|
||||
tables.get(row.table_name)!.columns.push({
|
||||
name: row.column_name,
|
||||
type: row.data_type,
|
||||
nullable: row.is_nullable === "YES",
|
||||
default: row.column_default,
|
||||
});
|
||||
}
|
||||
|
||||
// Group foreign keys by source table
|
||||
const foreignKeys = new Map<
|
||||
string,
|
||||
{ source_column: string; target_table: string; target_column: string }[]
|
||||
>();
|
||||
for (const row of fkResult.rows) {
|
||||
if (!foreignKeys.has(row.source_table)) {
|
||||
foreignKeys.set(row.source_table, []);
|
||||
}
|
||||
foreignKeys.get(row.source_table)!.push({
|
||||
source_column: row.source_column,
|
||||
target_table: row.target_table,
|
||||
target_column: row.target_column,
|
||||
});
|
||||
}
|
||||
|
||||
// Format output
|
||||
const lines: string[] = [];
|
||||
for (const [tableName, table] of tables) {
|
||||
lines.push(`${table.type}: ${tableName}`);
|
||||
for (const col of table.columns) {
|
||||
const parts = [` - ${col.name}: ${col.type}`];
|
||||
if (col.nullable) parts.push("(nullable)");
|
||||
if (col.default) parts.push(`default: ${col.default}`);
|
||||
lines.push(parts.join(" "));
|
||||
}
|
||||
const fks = foreignKeys.get(tableName);
|
||||
if (fks && fks.length > 0) {
|
||||
lines.push(" Foreign Keys:");
|
||||
for (const fk of fks) {
|
||||
lines.push(
|
||||
` - ${fk.source_column} -> ${fk.target_table}.${fk.target_column}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Database: query with RLS ---
|
||||
|
||||
async function executeQueryWithRLS(
|
||||
sql: string,
|
||||
userToken: string,
|
||||
validate: (sql: string) => string | null,
|
||||
): Promise<
|
||||
{ success: true; data: unknown[] } | { success: false; error: string }
|
||||
> {
|
||||
const validationError = validate(sql);
|
||||
if (validationError) {
|
||||
return { success: false, error: validationError };
|
||||
}
|
||||
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
const jwtClaims = decodeJwt(userToken);
|
||||
const claimsJson = JSON.stringify(jwtClaims);
|
||||
|
||||
await client.queryObject("BEGIN");
|
||||
// set_config(..., is_local=true) is the parameterized equivalent of
|
||||
// SET LOCAL — avoids interpolating JWT claims into a SQL string.
|
||||
await client.queryObject(
|
||||
"SELECT set_config('role', 'authenticated', true)",
|
||||
);
|
||||
await client.queryObject({
|
||||
text: "SELECT set_config('request.jwt.claims', $1, true)",
|
||||
args: [claimsJson],
|
||||
});
|
||||
|
||||
const result = await client.queryObject(sql);
|
||||
await client.queryObject("COMMIT");
|
||||
|
||||
// Convert BigInt values to numbers (Deno Postgres returns bigint for
|
||||
// PostgreSQL int8/count results, but JSON.stringify can't handle them)
|
||||
const rows = JSON.parse(
|
||||
JSON.stringify(result.rows, (_key, value) =>
|
||||
typeof value === "bigint" ? Number(value) : value,
|
||||
),
|
||||
);
|
||||
return { success: true, data: rows };
|
||||
} catch (error) {
|
||||
try {
|
||||
await client.queryObject("ROLLBACK");
|
||||
} catch {
|
||||
// Ignore rollback errors
|
||||
}
|
||||
const message =
|
||||
error instanceof AggregateError
|
||||
? error.errors.map((e) => e.message).join("; ")
|
||||
: error instanceof Error
|
||||
? error.message
|
||||
: String(error);
|
||||
return { success: false, error: message };
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
// --- MCP Server Factory ---
|
||||
|
||||
function createMcpServer(authInfo: AuthInfo): McpServer {
|
||||
const server = new McpServer({
|
||||
name: "atomic-crm",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
server.registerTool(
|
||||
"get_schema",
|
||||
{
|
||||
title: "Get Database Schema",
|
||||
description:
|
||||
"Retrieve the database schema for the user's Atomic CRM instance including all tables, views, columns, types, and foreign key relationships. Views (like contacts_summary, companies_summary) are read-only and provide pre-joined/aggregated data. Use them for search and list queries.",
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async () => {
|
||||
const schema = await getSchemaData();
|
||||
return { content: [{ type: "text" as const, text: schema }] };
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"query",
|
||||
{
|
||||
title: "Query CRM Data",
|
||||
description: `Read data from the user's CRM instance using SQL SELECT queries.
|
||||
|
||||
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
|
||||
|
||||
Use this tool when the user asks about their CRM data such as:
|
||||
- Contacts, companies, and deals
|
||||
- Sales pipeline and forecasting data
|
||||
- Customer interactions and notes
|
||||
- Tasks and follow-ups
|
||||
- Custom fields and metadata
|
||||
|
||||
Row Level Security (RLS) is enforced - queries automatically return only data the authenticated user has permission to access.
|
||||
|
||||
Use the *_summary views (contacts_summary, companies_summary) for queries that need aggregated data or search capabilities.
|
||||
|
||||
To filter by the current user, if the table has a sales_id column, add a WHERE sales_id = auth.uid() clause to your query.
|
||||
|
||||
This tool only supports SELECT queries. For INSERT, UPDATE, or DELETE operations, use the mutate tool.
|
||||
|
||||
Examples:
|
||||
- "SELECT id, first_name, last_name, email_fts FROM contacts_summary WHERE email_fts LIKE '%@company.com%'"
|
||||
- "SELECT name, stage, amount FROM deals WHERE created_at > NOW() - INTERVAL '30 days' ORDER BY amount DESC"
|
||||
- "SELECT COUNT(*) as total_tasks, type FROM tasks WHERE done_date IS NULL GROUP BY type"
|
||||
- "SELECT c.first_name, c.last_name, co.name as company_name FROM contacts c JOIN companies co ON c.company_id = co.id WHERE co.sector = 'Technology'"`,
|
||||
inputSchema: z.object({
|
||||
sql: z.string().describe("The SQL SELECT query to execute"),
|
||||
}),
|
||||
annotations: { readOnlyHint: true },
|
||||
},
|
||||
async ({ sql }: { sql: string }) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[MCP query] user=${authInfo.userId} sql=${sql}`);
|
||||
const result = await executeQueryWithRLS(
|
||||
sql,
|
||||
authInfo.token,
|
||||
validateReadOnly,
|
||||
);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"mutate",
|
||||
{
|
||||
title: "Mutate CRM Data",
|
||||
description: `Create, update, or delete data in the user's CRM instance using SQL.
|
||||
|
||||
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
|
||||
|
||||
Use this tool for data modifications such as:
|
||||
- Creating new contacts, companies, deals, tasks, or notes
|
||||
- Updating existing records
|
||||
- Deleting records
|
||||
|
||||
Row Level Security (RLS) is enforced - mutations only affect data the authenticated user has permission to modify.
|
||||
|
||||
IMPORTANT: Never specify sales_id in INSERT or UPDATE statements — it is automatically set to the authenticated user by a database trigger.
|
||||
|
||||
For read-only queries, use the query tool instead.
|
||||
|
||||
Examples:
|
||||
- "INSERT INTO contacts (first_name, last_name, email) VALUES ('John', 'Doe', 'john@example.com')"
|
||||
- "UPDATE deals SET stage = 'won-deal' WHERE id = 123"
|
||||
- "DELETE FROM tasks WHERE id = 456"`,
|
||||
inputSchema: z.object({
|
||||
sql: z
|
||||
.string()
|
||||
.describe("The SQL INSERT, UPDATE, or DELETE statement to execute"),
|
||||
}),
|
||||
annotations: { destructiveHint: true },
|
||||
},
|
||||
async ({ sql }: { sql: string }) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[MCP mutate] user=${authInfo.userId} sql=${sql}`);
|
||||
const result = await executeQueryWithRLS(
|
||||
sql,
|
||||
authInfo.token,
|
||||
validateWrite,
|
||||
);
|
||||
if (result.success) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(result.data, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
// --- UI resource for the task-list MCP App ---
|
||||
|
||||
// Inject the CRM base URL into the task-list guest HTML
|
||||
// so contact names can link back to the CRM
|
||||
const taskListHtml = TASK_LIST_HTML.replace(
|
||||
/__CRM_BASE_URL__/g,
|
||||
CRM_BASE_URL,
|
||||
);
|
||||
|
||||
server.registerResource(
|
||||
"task-list-ui",
|
||||
TASK_LIST_UI_URI,
|
||||
{
|
||||
title: "Task List UI",
|
||||
description: "Interactive list of tasks with mark-as-done buttons.",
|
||||
mimeType: "text/html;profile=mcp-app",
|
||||
},
|
||||
async (uri: URL) => ({
|
||||
contents: [
|
||||
{
|
||||
uri: uri.href,
|
||||
mimeType: "text/html;profile=mcp-app",
|
||||
text: taskListHtml,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const taskSchema = z.object({
|
||||
id: z
|
||||
.number()
|
||||
.int()
|
||||
.describe("Task id — required for the mark-as-done action"),
|
||||
text: z.string().nullable().optional().describe("Task description"),
|
||||
type: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Task category/type (rendered as a pill)"),
|
||||
due_date: z.string().nullable().optional().describe("ISO date string"),
|
||||
done_date: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("ISO timestamp if already done; null or omitted for pending"),
|
||||
contact_name: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe("Full name of the linked contact, if any"),
|
||||
contact_id: z
|
||||
.number()
|
||||
.int()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe(
|
||||
"Id of the linked contact — used to render the contact name as a link to the CRM contact page",
|
||||
),
|
||||
});
|
||||
type Task = z.infer<typeof taskSchema>;
|
||||
|
||||
server.registerTool(
|
||||
"display_task_list",
|
||||
{
|
||||
title: "Display Task List",
|
||||
description: `Render an array of task rows as an interactive UI (MCP App) where the user can mark each task as done.
|
||||
|
||||
This tool is presentational: it does not query the database. Fetch the rows yourself via the query tool (joining contacts for contact_name when useful), then pass them here. Prefer this over replying with a bulleted list of tasks.
|
||||
|
||||
Each task should include at least: id (required, used for the mark-as-done action), text, type, due_date, done_date, and optionally contact_name + contact_id (the UI renders the name as a link to the CRM contact page when contact_id is provided).`,
|
||||
inputSchema: {
|
||||
tasks: z.array(taskSchema).describe("Array of task objects to render"),
|
||||
},
|
||||
annotations: { readOnlyHint: true },
|
||||
_meta: {
|
||||
ui: {
|
||||
resourceUri: TASK_LIST_UI_URI,
|
||||
visibility: ["model"],
|
||||
},
|
||||
},
|
||||
},
|
||||
({ tasks }: { tasks: Task[] }) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[MCP display_task_list] user=${authInfo.userId} count=${tasks.length}`,
|
||||
);
|
||||
// content carries the display text (used by Claude's guest HTML);
|
||||
// structuredContent carries the typed data (used by ChatGPT's Apps SDK
|
||||
// convention). Supplying both keeps the guest host-agnostic.
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify(tasks) }],
|
||||
structuredContent: { tasks },
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
server.registerTool(
|
||||
"complete_task",
|
||||
{
|
||||
title: "Mark Task Done",
|
||||
description:
|
||||
"Mark a single task as done by id. Used by the task-list UI when the user clicks a task's checkmark, and also callable directly by the model.",
|
||||
inputSchema: {
|
||||
id: z
|
||||
.number()
|
||||
.int()
|
||||
.positive()
|
||||
.describe("The id of the task to mark as done"),
|
||||
},
|
||||
annotations: { idempotentHint: true },
|
||||
_meta: {
|
||||
ui: {
|
||||
visibility: ["model", "app"],
|
||||
},
|
||||
},
|
||||
},
|
||||
async ({ id }: { id: number }) => {
|
||||
// RETURNING id lets us distinguish a successful update from an
|
||||
// RLS-blocked or non-existent row (executeQueryWithRLS would otherwise
|
||||
// report success on 0 rows affected).
|
||||
const sql = `UPDATE tasks SET done_date = NOW() WHERE id = ${id} RETURNING id`;
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`[MCP complete_task] user=${authInfo.userId} id=${id}`);
|
||||
const result = await executeQueryWithRLS(
|
||||
sql,
|
||||
authInfo.token,
|
||||
validateWrite,
|
||||
);
|
||||
if (!result.success) {
|
||||
return {
|
||||
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
if (result.data.length === 0) {
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `Error: task ${id} not found or permission denied.`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [
|
||||
{ type: "text" as const, text: `Task ${id} marked as done.` },
|
||||
],
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
// --- OAuth Protected Resource Metadata ---
|
||||
|
||||
function handleProtectedResourceMetadata(req: Request): Response {
|
||||
const baseUrl = getBaseUrl(req);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
resource: `${baseUrl}/functions/v1/mcp`,
|
||||
authorization_servers: [`${baseUrl}/auth/v1`],
|
||||
bearer_methods_supported: ["header"],
|
||||
}),
|
||||
{
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// --- MCP Request Handler ---
|
||||
|
||||
async function handleMcpRequest(req: Request): Promise<Response> {
|
||||
// Validate auth
|
||||
const authInfo = await validateToken(req);
|
||||
if (!authInfo) {
|
||||
const metadataUrl = getResourceMetadataUrl(req);
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Create stateless MCP server + transport for this request
|
||||
const server = createMcpServer(authInfo);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
sessionIdGenerator: undefined, // Stateless
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
// Clean up server + transport when the connection closes.
|
||||
// Do NOT close in a finally block — the SSE response body is a
|
||||
// ReadableStream that must remain open until the client consumes it.
|
||||
transport.onclose = () => {
|
||||
server.close().catch(() => {});
|
||||
};
|
||||
|
||||
try {
|
||||
return await transport.handleRequest(req);
|
||||
} catch (error) {
|
||||
console.error("MCP request error:", error);
|
||||
await transport.close();
|
||||
await server.close();
|
||||
return new Response("Internal Server Error", { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// --- CORS Helper ---
|
||||
|
||||
function withCorsHeaders(response: Response): Response {
|
||||
const headers = new Headers(response.headers);
|
||||
for (const [key, value] of Object.entries(corsHeaders)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Route Dispatcher ---
|
||||
|
||||
Deno.serve(async (req: Request) => {
|
||||
// Handle CORS preflight
|
||||
if (req.method === "OPTIONS") {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const path = url.pathname;
|
||||
|
||||
// GET /functions/v1/mcp/oauth-protected-resource → RFC 9728 metadata
|
||||
if (path.endsWith("/oauth-protected-resource") && req.method === "GET") {
|
||||
return withCorsHeaders(handleProtectedResourceMetadata(req));
|
||||
}
|
||||
|
||||
// POST/GET/DELETE /functions/v1/mcp → MCP protocol handler
|
||||
if (path.endsWith("/mcp") || path.endsWith("/mcp/")) {
|
||||
return withCorsHeaders(await handleMcpRequest(req));
|
||||
}
|
||||
|
||||
return withCorsHeaders(new Response("Not Found", { status: 404 }));
|
||||
});
|
||||
349
supabase/functions/mcp/taskListUi.ts
Normal file
349
supabase/functions/mcp/taskListUi.ts
Normal file
@@ -0,0 +1,349 @@
|
||||
// Task-list MCP App. Everything lives in this single file: template,
|
||||
// styles, and glue. Plain vanilla JS — no external scripts, because host
|
||||
// CSPs (e.g. Claude.ai) only allow 'self' + 'unsafe-inline' for scripts.
|
||||
// Syntax highlighted if the IDE has the es6-string-html extension enabled.
|
||||
|
||||
export const TASK_LIST_UI_URI = "ui://atomic-crm/task-list";
|
||||
|
||||
export const TASK_LIST_HTML = /*html*/ `
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'">
|
||||
<title>Tasks</title>
|
||||
<style>
|
||||
:root { color-scheme: light dark; }
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-size:14px; background:transparent; color:#111; }
|
||||
body { padding:8px 4px; overflow:hidden; }
|
||||
.task { display:flex; align-items:flex-start; gap:10px; padding:10px 12px; border:1px solid rgba(128,128,128,0.25); border-radius:8px; margin-bottom:8px; background:rgba(0,0,0,0.02); transition:opacity 0.2s ease; }
|
||||
.task.done { opacity:0.45; }
|
||||
.task.updating .check { opacity:0.5; cursor:wait; }
|
||||
.check { width:20px; height:20px; min-width:20px; margin-top:1px; border:1.5px solid #888; border-radius:999px; background:transparent; cursor:pointer; padding:0; display:inline-flex; align-items:center; justify-content:center; color:transparent; font-size:12px; line-height:1; transition:background 0.15s, border-color 0.15s, color 0.15s; }
|
||||
.check:hover:not(:disabled) { border-color:#2563eb; background:rgba(37,99,235,0.12); }
|
||||
.check.checked { background:#10b981; border-color:#10b981; color:white; }
|
||||
.check.checked::after { content:"\\2713"; }
|
||||
.task-content { flex:1; min-width:0; }
|
||||
.task-text { font-weight:500; white-space:pre-wrap; word-break:break-word; }
|
||||
.task-meta { margin-top:3px; font-size:12px; color:#6b7280; display:flex; flex-wrap:wrap; gap:10px; }
|
||||
.pill { display:inline-block; padding:1px 6px; border-radius:4px; background:rgba(128,128,128,0.22); color:#374151; }
|
||||
.contact { color:#2563eb; font-weight:500; }
|
||||
a.contact { text-decoration:none; }
|
||||
a.contact:hover { text-decoration:underline; }
|
||||
.empty { text-align:center; color:#6b7280; padding:16px 8px; }
|
||||
.error { padding:8px 10px; border-radius:6px; border:1px solid rgba(220,38,38,0.4); background:rgba(220,38,38,0.08); color:#dc2626; font-size:12px; margin-bottom:8px; }
|
||||
html.dark body { color:#f5f5f5; }
|
||||
html.dark .task { background:rgba(255,255,255,0.04); }
|
||||
html.dark .task-meta { color:#b8b8b8; }
|
||||
html.dark .pill { color:#e5e5e5; }
|
||||
html.dark .contact { color:#93c5fd; }
|
||||
html.dark .empty { color:#b8b8b8; }
|
||||
html.dark .error { color:#f87171; }
|
||||
html.dark .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not(.light) body { color:#f5f5f5; }
|
||||
html:not(.light) .task { background:rgba(255,255,255,0.04); }
|
||||
html:not(.light) .task-meta { color:#b8b8b8; }
|
||||
html:not(.light) .pill { color:#e5e5e5; }
|
||||
html:not(.light) .contact { color:#93c5fd; }
|
||||
html:not(.light) .empty { color:#b8b8b8; }
|
||||
html:not(.light) .error { color:#f87171; }
|
||||
html:not(.light) .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(() => {
|
||||
// Injected server-side; empty string when not configured, in which case
|
||||
// contact names render as plain text instead of links.
|
||||
const CRM_BASE_URL = '__CRM_BASE_URL__';
|
||||
|
||||
// -------- JSON-RPC plumbing over postMessage --------
|
||||
const pending = new Map();
|
||||
const RPC_TIMEOUT_MS = 10000;
|
||||
let nextId = 1;
|
||||
|
||||
const post = (msg) => window.parent.postMessage(msg, '*');
|
||||
|
||||
const rpc = (method, params) => new Promise((resolve, reject) => {
|
||||
const id = nextId++;
|
||||
const timer = setTimeout(() => {
|
||||
if (!pending.has(id)) return;
|
||||
pending.delete(id);
|
||||
reject(new Error("RPC timeout after " + RPC_TIMEOUT_MS + "ms: " + method));
|
||||
}, RPC_TIMEOUT_MS);
|
||||
pending.set(id, { resolve, reject, timer });
|
||||
post({ jsonrpc: '2.0', id, method, params });
|
||||
});
|
||||
|
||||
// -------- State --------
|
||||
let tasks = [];
|
||||
let errorMsg = '';
|
||||
let ready = false;
|
||||
const updating = Object.create(null);
|
||||
|
||||
// -------- Rendering (vanilla DOM) --------
|
||||
const root = document.getElementById('app');
|
||||
|
||||
const contactUrl = (id) =>
|
||||
CRM_BASE_URL ? CRM_BASE_URL + '/#/contacts/' + id + '/show' : '';
|
||||
|
||||
const formatDate = (value) => {
|
||||
const d = new Date(value);
|
||||
if (isNaN(d.getTime())) return String(value);
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
};
|
||||
|
||||
const el = (tag, attrs, children) => {
|
||||
const node = document.createElement(tag);
|
||||
if (attrs) {
|
||||
for (const k in attrs) {
|
||||
const v = attrs[k];
|
||||
if (v == null || v === false) continue;
|
||||
if (k === 'class') node.className = v;
|
||||
else if (k === 'text') node.textContent = v;
|
||||
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
|
||||
else node.setAttribute(k, v === true ? '' : String(v));
|
||||
}
|
||||
}
|
||||
if (children) {
|
||||
for (const c of children) {
|
||||
if (c == null || c === false) continue;
|
||||
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
|
||||
}
|
||||
}
|
||||
return node;
|
||||
};
|
||||
|
||||
const render = () => {
|
||||
root.replaceChildren();
|
||||
|
||||
if (errorMsg) {
|
||||
root.appendChild(el('div', { class: 'error', text: errorMsg }));
|
||||
}
|
||||
|
||||
if (!ready) {
|
||||
root.appendChild(el('div', { class: 'empty', text: 'Waiting for data\u2026' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!tasks.length) {
|
||||
root.appendChild(el('div', { class: 'empty', text: 'No pending tasks.' }));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const t of tasks) {
|
||||
const done = !!t.done_date;
|
||||
const upd = !!updating[t.id];
|
||||
|
||||
const classes = ['task'];
|
||||
if (done) classes.push('done');
|
||||
if (upd) classes.push('updating');
|
||||
|
||||
const meta = [];
|
||||
if (t.contact_name && t.contact_id && contactUrl(t.contact_id)) {
|
||||
meta.push(el('a', {
|
||||
class: 'contact',
|
||||
href: contactUrl(t.contact_id),
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
text: t.contact_name,
|
||||
}));
|
||||
} else if (t.contact_name) {
|
||||
meta.push(el('span', { class: 'contact', text: t.contact_name }));
|
||||
}
|
||||
if (t.type) meta.push(el('span', { class: 'pill', text: t.type }));
|
||||
if (t.due_date) meta.push(el('span', { text: 'Due ' + formatDate(t.due_date) }));
|
||||
|
||||
const checkClasses = ['check'];
|
||||
if (done) checkClasses.push('checked');
|
||||
|
||||
const card = el('div', { class: classes.join(' ') }, [
|
||||
el('button', {
|
||||
type: 'button',
|
||||
class: checkClasses.join(' '),
|
||||
'aria-label': done ? 'Already done' : 'Mark as done',
|
||||
disabled: done,
|
||||
onclick: () => complete(t),
|
||||
}),
|
||||
el('div', { class: 'task-content' }, [
|
||||
el('div', { class: 'task-text', text: t.text || '(no description)' }),
|
||||
meta.length ? el('div', { class: 'task-meta' }, meta) : null,
|
||||
]),
|
||||
]);
|
||||
|
||||
root.appendChild(card);
|
||||
}
|
||||
};
|
||||
|
||||
// -------- Actions --------
|
||||
const complete = (t) => {
|
||||
if (t.done_date || updating[t.id]) return;
|
||||
updating[t.id] = true;
|
||||
render();
|
||||
rpc('tools/call', { name: 'complete_task', arguments: { id: t.id } })
|
||||
.then((result) => {
|
||||
updating[t.id] = false;
|
||||
if (result && result.isError) {
|
||||
errorMsg = (result.content && result.content[0] && result.content[0].text) || 'Failed to mark task as done';
|
||||
} else {
|
||||
errorMsg = '';
|
||||
t.done_date = new Date().toISOString();
|
||||
}
|
||||
render();
|
||||
})
|
||||
.catch((e) => {
|
||||
updating[t.id] = false;
|
||||
errorMsg = 'Could not mark as done: ' + ((e && e.message) || String(e));
|
||||
render();
|
||||
});
|
||||
};
|
||||
|
||||
// Extract an array of tasks from a tool result, handling the multiple
|
||||
// shapes hosts use. Claude sends the JSON-stringified array in
|
||||
// content[0].text. ChatGPT (OpenAI Apps SDK convention) instead populates
|
||||
// structuredContent with typed data — either the array directly or a
|
||||
// wrapper object like { tasks: [...] }.
|
||||
const extractTasks = (result) => {
|
||||
if (!result) return null;
|
||||
const sc = result.structuredContent;
|
||||
if (Array.isArray(sc)) return sc;
|
||||
if (sc && Array.isArray(sc.tasks)) return sc.tasks;
|
||||
const text = result.content && result.content[0] && result.content[0].text;
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) return parsed;
|
||||
if (parsed && Array.isArray(parsed.tasks)) return parsed.tasks;
|
||||
} catch (_) {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const applyToolResult = (result) => {
|
||||
const extracted = extractTasks(result);
|
||||
if (extracted) {
|
||||
tasks = extracted;
|
||||
errorMsg = '';
|
||||
} else {
|
||||
tasks = [];
|
||||
errorMsg = 'Could not extract task list from tool result';
|
||||
}
|
||||
ready = true;
|
||||
render();
|
||||
};
|
||||
|
||||
const applyToolInput = (input) => {
|
||||
const args = input && input.arguments;
|
||||
if (args && Array.isArray(args.tasks)) {
|
||||
tasks = args.tasks;
|
||||
errorMsg = '';
|
||||
ready = true;
|
||||
render();
|
||||
}
|
||||
};
|
||||
|
||||
// -------- Bootstrap --------
|
||||
window.addEventListener('message', (event) => {
|
||||
// Origin-agnostic (the host's origin is unknown at build time);
|
||||
// just assert the message came from the parent frame.
|
||||
if (event.source !== window.parent) return;
|
||||
const m = event.data;
|
||||
if (!m || m.jsonrpc !== '2.0') return;
|
||||
if (m.id !== undefined && m.method === undefined) {
|
||||
const w = pending.get(m.id);
|
||||
if (!w) return;
|
||||
pending.delete(m.id);
|
||||
clearTimeout(w.timer);
|
||||
if (m.error) w.reject(new Error(m.error.message || 'RPC error'));
|
||||
else w.resolve(m.result);
|
||||
return;
|
||||
}
|
||||
if (m.method === 'ui/notifications/tool-result') {
|
||||
applyToolResult(m.params);
|
||||
} else if (
|
||||
m.method === 'ui/notifications/tool-input-partial' ||
|
||||
m.method === 'ui/notifications/tool-input'
|
||||
) {
|
||||
// Render progressively from streamed tool arguments so the user sees
|
||||
// tasks appear as the model generates them instead of a blank wait.
|
||||
// Claude uses tool-input-partial; ChatGPT streams via tool-input.
|
||||
applyToolInput(m.params);
|
||||
} else if (
|
||||
m.method === 'ui/notifications/host-context-changed' ||
|
||||
m.method === 'ui/notifications/theme'
|
||||
) {
|
||||
const theme = m.params && m.params.theme;
|
||||
if (theme) {
|
||||
const cl = document.documentElement.classList;
|
||||
cl.toggle('dark', theme === 'dark');
|
||||
cl.toggle('light', theme === 'light');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Iframe has no intrinsic height from the host's perspective; measure our
|
||||
// own content and ask the host to resize via ui/notifications/size-changed
|
||||
// (per MCP Apps spec; hosts like Claude Desktop ignore non-spec ui/size).
|
||||
let lastReportedHeight = 0;
|
||||
const reportSize = () => {
|
||||
const h = Math.max(
|
||||
document.body.scrollHeight,
|
||||
document.body.offsetHeight,
|
||||
document.documentElement.scrollHeight,
|
||||
document.documentElement.offsetHeight,
|
||||
);
|
||||
const height = h + 2;
|
||||
if (height === lastReportedHeight) return;
|
||||
lastReportedHeight = height;
|
||||
post({
|
||||
jsonrpc: '2.0',
|
||||
method: 'ui/notifications/size-changed',
|
||||
params: { width: document.documentElement.clientWidth, height },
|
||||
});
|
||||
};
|
||||
|
||||
render();
|
||||
|
||||
// -------- Spec-compliant MCP Apps handshake --------
|
||||
// Per the MCP Apps spec, the host MUST NOT send any notification (including
|
||||
// tool-result) before it receives ui/notifications/initialized from the
|
||||
// view. We therefore: (1) send ui/initialize, (2) await the response,
|
||||
// (3) send ui/notifications/initialized, and only then wire up the
|
||||
// ResizeObserver so the host is not spammed with size reports before handshake.
|
||||
rpc('ui/initialize', {
|
||||
protocolVersion: '2025-06-18',
|
||||
appInfo: { name: 'atomic-crm-task-list', version: '1.0.0' },
|
||||
appCapabilities: {
|
||||
tools: { listChanged: false },
|
||||
availableDisplayModes: ['inline'],
|
||||
},
|
||||
})
|
||||
.then((result) => {
|
||||
// Apply theme from host context if provided
|
||||
const theme = result && result.hostContext && result.hostContext.theme;
|
||||
if (theme) {
|
||||
const cl = document.documentElement.classList;
|
||||
cl.toggle('dark', theme === 'dark');
|
||||
cl.toggle('light', theme === 'light');
|
||||
}
|
||||
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
new ResizeObserver(reportSize).observe(document.body);
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
errorMsg = 'Failed to initialize MCP App: ' + ((e && e.message) || e);
|
||||
render();
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
122
supabase/functions/mcp/validateSql.test.ts
Normal file
122
supabase/functions/mcp/validateSql.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment node
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { validateReadOnly, validateWrite } from "./validateSql";
|
||||
|
||||
describe("validateReadOnly", () => {
|
||||
it.each([
|
||||
["simple SELECT", "SELECT * FROM contacts"],
|
||||
[
|
||||
"SELECT with WHERE",
|
||||
"SELECT id, name FROM contacts WHERE created_at > NOW() - INTERVAL '30 days'",
|
||||
],
|
||||
[
|
||||
"SELECT with JOIN",
|
||||
"SELECT c.name, co.name FROM contacts c JOIN companies co ON c.company_id = co.id",
|
||||
],
|
||||
[
|
||||
"SELECT with subquery",
|
||||
"SELECT * FROM contacts WHERE company_id IN (SELECT id FROM companies WHERE sector = 'Tech')",
|
||||
],
|
||||
[
|
||||
"read-only CTE",
|
||||
"WITH recent AS (SELECT * FROM contacts WHERE created_at > NOW() - INTERVAL '7 days') SELECT * FROM recent",
|
||||
],
|
||||
[
|
||||
"aggregate query",
|
||||
"SELECT COUNT(*) as total, type FROM tasks GROUP BY type",
|
||||
],
|
||||
[
|
||||
"DELETE in string literal",
|
||||
"SELECT * FROM contacts WHERE status = 'DELETE'",
|
||||
],
|
||||
[
|
||||
"DROP in string literal",
|
||||
"SELECT * FROM logs WHERE message = 'DROP TABLE failed'",
|
||||
],
|
||||
[
|
||||
"UPDATE in string literal",
|
||||
"SELECT * FROM contacts WHERE note = 'Please UPDATE your info'",
|
||||
],
|
||||
[
|
||||
"DROP in block comment",
|
||||
"SELECT /* DROP TABLE contacts */ * FROM contacts",
|
||||
],
|
||||
["DROP in line comment", "SELECT * FROM contacts -- DROP TABLE contacts"],
|
||||
])("allows %s", (_label, sql) => {
|
||||
expect(validateReadOnly(sql)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["INSERT", "INSERT INTO contacts (name) VALUES ('test')"],
|
||||
["UPDATE", "UPDATE contacts SET name = 'test'"],
|
||||
["DELETE", "DELETE FROM contacts WHERE id = 1"],
|
||||
[
|
||||
"writable CTE (DELETE)",
|
||||
"WITH d AS (DELETE FROM contacts RETURNING *) SELECT * FROM d",
|
||||
],
|
||||
[
|
||||
"writable CTE (UPDATE)",
|
||||
"WITH u AS (UPDATE contacts SET name = 'x' RETURNING *) SELECT * FROM u",
|
||||
],
|
||||
[
|
||||
"writable CTE (INSERT)",
|
||||
"WITH i AS (INSERT INTO contacts (name) VALUES ('x') RETURNING *) SELECT * FROM i",
|
||||
],
|
||||
["DROP TABLE", "DROP TABLE contacts"],
|
||||
["CREATE TABLE", "CREATE TABLE evil (id int)"],
|
||||
["ALTER TABLE", "ALTER TABLE contacts ADD COLUMN x int"],
|
||||
["TRUNCATE", "TRUNCATE contacts"],
|
||||
["SET", "SET LOCAL role = 'postgres'"],
|
||||
["DO block", "DO $$ BEGIN END $$"],
|
||||
["multi-statement (SELECT; DROP)", "SELECT 1; DROP TABLE contacts"],
|
||||
["multi-statement (SELECT; SET)", "SELECT 1; SET LOCAL role = 'postgres'"],
|
||||
["multi-statement (two SELECTs)", "SELECT 1; SELECT 2"],
|
||||
["unparseable SQL", "NOT VALID SQL %%%"],
|
||||
])("rejects %s", (_label, sql) => {
|
||||
expect(validateReadOnly(sql)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateWrite", () => {
|
||||
it.each([
|
||||
[
|
||||
"INSERT",
|
||||
"INSERT INTO contacts (first_name, last_name) VALUES ('John', 'Doe')",
|
||||
],
|
||||
["UPDATE", "UPDATE contacts SET name = 'test' WHERE id = 1"],
|
||||
["DELETE", "DELETE FROM contacts WHERE id = 1"],
|
||||
[
|
||||
"INSERT with RETURNING",
|
||||
"INSERT INTO contacts (name) VALUES ('test') RETURNING id",
|
||||
],
|
||||
[
|
||||
"UPDATE with subquery",
|
||||
"UPDATE contacts SET company_id = (SELECT id FROM companies WHERE name = 'Acme') WHERE id = 1",
|
||||
],
|
||||
[
|
||||
"writable CTE with INSERT",
|
||||
"WITH d AS (DELETE FROM old_contacts RETURNING *) INSERT INTO archive SELECT * FROM d",
|
||||
],
|
||||
])("allows %s", (_label, sql) => {
|
||||
expect(validateWrite(sql)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["standalone SELECT", "SELECT * FROM contacts"],
|
||||
["DROP TABLE", "DROP TABLE contacts"],
|
||||
["CREATE TABLE", "CREATE TABLE evil (id int)"],
|
||||
["TRUNCATE", "TRUNCATE contacts"],
|
||||
["SET", "SET LOCAL role = 'postgres'"],
|
||||
[
|
||||
"multi-statement (DELETE; DROP)",
|
||||
"DELETE FROM contacts; DROP TABLE contacts",
|
||||
],
|
||||
[
|
||||
"multi-statement (DELETE; SET)",
|
||||
"DELETE FROM contacts; SET LOCAL role = 'postgres'",
|
||||
],
|
||||
["unparseable SQL", "NOT VALID SQL %%%"],
|
||||
])("rejects %s", (_label, sql) => {
|
||||
expect(validateWrite(sql)).not.toBeNull();
|
||||
});
|
||||
});
|
||||
73
supabase/functions/mcp/validateSql.ts
Normal file
73
supabase/functions/mcp/validateSql.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { parse, type Statement } from "npm:pgsql-ast-parser@^12";
|
||||
|
||||
const ALLOWED_READ_TYPES = new Set(["select", "with"]);
|
||||
const ALLOWED_WRITE_TYPES = new Set(["insert", "update", "delete", "with"]);
|
||||
|
||||
// Collect all statement types found in a parsed AST, including inner
|
||||
// statements in WITH (CTE) bindings which can contain writable DML.
|
||||
function collectStatementTypes(stmts: Statement[]): Set<string> {
|
||||
const types = new Set<string>();
|
||||
for (const stmt of stmts) {
|
||||
types.add(stmt.type);
|
||||
if (stmt.type === "with" && "bind" in stmt && Array.isArray(stmt.bind)) {
|
||||
for (const cte of stmt.bind) {
|
||||
if (cte.statement?.type) {
|
||||
types.add(cte.statement.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return types;
|
||||
}
|
||||
|
||||
export function validateReadOnly(sql: string): string | null {
|
||||
let stmts: Statement[];
|
||||
try {
|
||||
stmts = parse(sql);
|
||||
} catch (err) {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
// pgsql-ast-parser appends a parse-table dump that is useless to the
|
||||
// LLM and consumes many tokens; keep the diagnostic prefix only.
|
||||
const message = raw.split("Here is the state of my parse table")[0].trim();
|
||||
return `Failed to parse SQL: ${message}`;
|
||||
}
|
||||
if (stmts.length === 0) {
|
||||
return "Empty query.";
|
||||
}
|
||||
if (stmts.length > 1) {
|
||||
return "Only a single statement is allowed.";
|
||||
}
|
||||
const types = collectStatementTypes(stmts);
|
||||
for (const type of types) {
|
||||
if (!ALLOWED_READ_TYPES.has(type)) {
|
||||
return `Statement type "${type}" is not allowed in read-only queries. Use the mutate tool for data modifications.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateWrite(sql: string): string | null {
|
||||
let stmts: Statement[];
|
||||
try {
|
||||
stmts = parse(sql);
|
||||
} catch (err) {
|
||||
const raw = err instanceof Error ? err.message : String(err);
|
||||
// pgsql-ast-parser appends a parse-table dump that is useless to the
|
||||
// LLM and consumes many tokens; keep the diagnostic prefix only.
|
||||
const message = raw.split("Here is the state of my parse table")[0].trim();
|
||||
return `Failed to parse SQL: ${message}`;
|
||||
}
|
||||
if (stmts.length === 0) {
|
||||
return "Empty query.";
|
||||
}
|
||||
if (stmts.length > 1) {
|
||||
return "Only a single statement is allowed.";
|
||||
}
|
||||
const types = collectStatementTypes(stmts);
|
||||
for (const type of types) {
|
||||
if (!ALLOWED_WRITE_TYPES.has(type)) {
|
||||
return `Statement type "${type}" is not allowed. Only INSERT, UPDATE, and DELETE statements are supported.`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user