PostgREST
Official
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { AuthClient } from '@supabase/auth-js';
import { StreamTransport } from '@supabase/mcp-utils';
import { describe, expect, test } from 'vitest';
import { createPostgrestMcpServer } from './server.js';
// Requires local Supabase stack running
const API_URL = 'http://127.0.0.1:54321';
const REST_API_URL = `${API_URL}/rest/v1`;
const AUTH_API_URL = `${API_URL}/auth/v1`;
/**
* Sets up a client and server for testing.
*/
async function setup() {
const clientTransport = new StreamTransport();
const serverTransport = new StreamTransport();
clientTransport.readable.pipeTo(serverTransport.writable);
serverTransport.readable.pipeTo(clientTransport.writable);
const client = new Client(
{
name: 'TestClient',
version: '0.1.0',
},
{
capabilities: {},
}
);
const authClient = new AuthClient({
url: AUTH_API_URL,
});
await authClient.signUp({
email: 'john@example.com',
password: 'password',
});
const authResponse = await authClient.signInWithPassword({
email: 'john@example.com',
password: 'password',
});
if (authResponse.error) {
throw new Error(authResponse.error.message);
}
const server = createPostgrestMcpServer({
apiUrl: REST_API_URL,
schema: 'public',
apiKey: authResponse.data.session.access_token,
});
await server.connect(serverTransport);
await client.connect(clientTransport);
// Clear existing todos
const deleteOutput = await client.callTool({
name: 'postgrestRequest',
arguments: {
method: 'DELETE',
path: '/todos?id=gt.0',
},
});
if (deleteOutput.isError) {
throw new Error(JSON.stringify(deleteOutput.content));
}
const todoSeeds = [
{
title: 'Buy groceries',
description: 'Purchase milk, eggs, and bread from the store',
due_date: '2023-10-15',
is_completed: false,
},
{
title: 'Complete project report',
description:
'Finalize and submit the project report by the end of the week',
due_date: '2023-10-20',
is_completed: false,
},
{
title: 'Doctor appointment',
description: 'Annual check-up with Dr. Smith at 10 AM',
due_date: '2023-10-18',
is_completed: false,
},
{
title: 'Call plumber',
description: 'Fix the leaking sink in the kitchen',
due_date: '2023-10-16',
is_completed: false,
},
{
title: 'Read book',
description: 'Finish reading "The Great Gatsby"',
due_date: '2023-10-22',
is_completed: false,
},
];
// Seed todos
const output = await client.callTool({
name: 'postgrestRequest',
arguments: {
method: 'POST',
path: '/todos',
body: todoSeeds,
},
});
if (output.isError) {
throw new Error(JSON.stringify(output.content));
}
return { client, clientTransport, server, serverTransport };
}
describe('resources', () => {
test('list', async () => {
const { client } = await setup();
const { resources } = await client.listResources();
expect(resources).toHaveLength(1);
const [firstResource] = resources;
if (!firstResource) {
throw new Error('no resources');
}
expect(firstResource).toMatchInlineSnapshot(`
{
"description": "OpenAPI spec for the PostgREST API",
"mimeType": "application/json",
"name": "OpenAPI spec",
"uri": "postgrest:///spec",
}
`);
});
test('read', async () => {
const { client } = await setup();
const { contents } = await client.readResource({
uri: 'postgrest:///spec',
});
const [firstContent] = contents;
expect(firstContent).toMatchInlineSnapshot(`
{
"mimeType": "application/json",
"text": "{"swagger":"2.0","info":{"description":"","title":"standard public schema","version":"12.2.0 (ec89f6b)"},"host":"0.0.0.0:3000","basePath":"/","schemes":["http"],"consumes":["application/json","application/vnd.pgrst.object+json;nulls=stripped","application/vnd.pgrst.object+json","text/csv"],"produces":["application/json","application/vnd.pgrst.object+json;nulls=stripped","application/vnd.pgrst.object+json","text/csv"],"paths":{"/":{"get":{"produces":["application/openapi+json","application/json"],"responses":{"200":{"description":"OK"}},"summary":"OpenAPI description (this document)","tags":["Introspection"]}},"/todos":{"get":{"parameters":[{"$ref":"#/parameters/rowFilter.todos.id"},{"$ref":"#/parameters/rowFilter.todos.title"},{"$ref":"#/parameters/rowFilter.todos.description"},{"$ref":"#/parameters/rowFilter.todos.due_date"},{"$ref":"#/parameters/rowFilter.todos.is_completed"},{"$ref":"#/parameters/rowFilter.todos.user_id"},{"$ref":"#/parameters/select"},{"$ref":"#/parameters/order"},{"$ref":"#/parameters/range"},{"$ref":"#/parameters/rangeUnit"},{"$ref":"#/parameters/offset"},{"$ref":"#/parameters/limit"},{"$ref":"#/parameters/preferCount"}],"responses":{"200":{"description":"OK","schema":{"items":{"$ref":"#/definitions/todos"},"type":"array"}},"206":{"description":"Partial Content"}},"summary":"Table to manage todo items with details such as title, description, due date, and completion status.","tags":["todos"]},"post":{"parameters":[{"$ref":"#/parameters/body.todos"},{"$ref":"#/parameters/select"},{"$ref":"#/parameters/preferPost"}],"responses":{"201":{"description":"Created"}},"summary":"Table to manage todo items with details such as title, description, due date, and completion status.","tags":["todos"]},"delete":{"parameters":[{"$ref":"#/parameters/rowFilter.todos.id"},{"$ref":"#/parameters/rowFilter.todos.title"},{"$ref":"#/parameters/rowFilter.todos.description"},{"$ref":"#/parameters/rowFilter.todos.due_date"},{"$ref":"#/parameters/rowFilter.todos.is_completed"},{"$ref":"#/parameters/rowFilter.todos.user_id"},{"$ref":"#/parameters/preferReturn"}],"responses":{"204":{"description":"No Content"}},"summary":"Table to manage todo items with details such as title, description, due date, and completion status.","tags":["todos"]},"patch":{"parameters":[{"$ref":"#/parameters/rowFilter.todos.id"},{"$ref":"#/parameters/rowFilter.todos.title"},{"$ref":"#/parameters/rowFilter.todos.description"},{"$ref":"#/parameters/rowFilter.todos.due_date"},{"$ref":"#/parameters/rowFilter.todos.is_completed"},{"$ref":"#/parameters/rowFilter.todos.user_id"},{"$ref":"#/parameters/body.todos"},{"$ref":"#/parameters/preferReturn"}],"responses":{"204":{"description":"No Content"}},"summary":"Table to manage todo items with details such as title, description, due date, and completion status.","tags":["todos"]}}},"definitions":{"todos":{"description":"Table to manage todo items with details such as title, description, due date, and completion status.","required":["id","title","user_id"],"properties":{"id":{"description":"Note:\\nThis is a Primary Key.<pk/>","format":"bigint","type":"integer"},"title":{"format":"text","type":"string"},"description":{"format":"text","type":"string"},"due_date":{"format":"date","type":"string"},"is_completed":{"default":false,"format":"boolean","type":"boolean"},"user_id":{"default":"auth.uid()","format":"uuid","type":"string"}},"type":"object"}},"parameters":{"preferParams":{"name":"Prefer","description":"Preference","required":false,"enum":["params=single-object"],"in":"header","type":"string"},"preferReturn":{"name":"Prefer","description":"Preference","required":false,"enum":["return=representation","return=minimal","return=none"],"in":"header","type":"string"},"preferCount":{"name":"Prefer","description":"Preference","required":false,"enum":["count=none"],"in":"header","type":"string"},"preferPost":{"name":"Prefer","description":"Preference","required":false,"enum":["return=representation","return=minimal","return=none","resolution=ignore-duplicates","resolution=merge-duplicates"],"in":"header","type":"string"},"select":{"name":"select","description":"Filtering Columns","required":false,"in":"query","type":"string"},"on_conflict":{"name":"on_conflict","description":"On Conflict","required":false,"in":"query","type":"string"},"order":{"name":"order","description":"Ordering","required":false,"in":"query","type":"string"},"range":{"name":"Range","description":"Limiting and Pagination","required":false,"in":"header","type":"string"},"rangeUnit":{"name":"Range-Unit","description":"Limiting and Pagination","required":false,"default":"items","in":"header","type":"string"},"offset":{"name":"offset","description":"Limiting and Pagination","required":false,"in":"query","type":"string"},"limit":{"name":"limit","description":"Limiting and Pagination","required":false,"in":"query","type":"string"},"body.todos":{"name":"todos","description":"todos","required":false,"in":"body","schema":{"$ref":"#/definitions/todos"}},"rowFilter.todos.id":{"name":"id","required":false,"format":"bigint","in":"query","type":"string"},"rowFilter.todos.title":{"name":"title","required":false,"format":"text","in":"query","type":"string"},"rowFilter.todos.description":{"name":"description","required":false,"format":"text","in":"query","type":"string"},"rowFilter.todos.due_date":{"name":"due_date","required":false,"format":"date","in":"query","type":"string"},"rowFilter.todos.is_completed":{"name":"is_completed","required":false,"format":"boolean","in":"query","type":"string"},"rowFilter.todos.user_id":{"name":"user_id","required":false,"format":"uuid","in":"query","type":"string"}},"externalDocs":{"description":"PostgREST Documentation","url":"https://postgrest.org/en/v12.2/api.html"}}",
"uri": "postgrest:///spec",
}
`);
});
});
describe('tools', () => {
test('list', async () => {
const { client } = await setup();
const { tools } = await client.listTools();
expect(tools).toHaveLength(2);
const [firstTool, secondTool] = tools;
if (!firstTool) {
throw new Error('no tools');
}
expect(firstTool).toMatchInlineSnapshot(`
{
"description": "Performs an HTTP request against the PostgREST API",
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"body": {
"additionalProperties": {},
"type": "object",
},
"method": {
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
],
"type": "string",
},
"path": {
"type": "string",
},
},
"required": [
"method",
"path",
],
"type": "object",
},
"name": "postgrestRequest",
}
`);
if (!secondTool) {
throw new Error('missing second tool');
}
expect(secondTool).toMatchInlineSnapshot(`
{
"description": "Converts SQL query to a PostgREST API request (method, path)",
"inputSchema": {
"$schema": "http://json-schema.org/draft-07/schema#",
"additionalProperties": false,
"properties": {
"sql": {
"type": "string",
},
},
"required": [
"sql",
],
"type": "object",
},
"name": "sqlToRest",
}
`);
});
test('execute', async () => {
const { client } = await setup();
const output = await client.callTool({
name: 'postgrestRequest',
arguments: {
method: 'GET',
path: '/todos?select=title,description,due_date,is_completed&order=id.asc',
},
});
const [firstContent] = output.content as any[];
if (!firstContent) {
throw new Error('no content');
}
const result = JSON.parse(firstContent.text);
expect(result).toMatchInlineSnapshot([
{
description: 'Purchase milk, eggs, and bread from the store',
due_date: '2023-10-15',
is_completed: false,
title: 'Buy groceries',
},
{
description:
'Finalize and submit the project report by the end of the week',
due_date: '2023-10-20',
is_completed: false,
title: 'Complete project report',
},
{
description: 'Annual check-up with Dr. Smith at 10 AM',
due_date: '2023-10-18',
is_completed: false,
title: 'Doctor appointment',
},
{
description: 'Fix the leaking sink in the kitchen',
due_date: '2023-10-16',
is_completed: false,
title: 'Call plumber',
},
{
description: 'Finish reading "The Great Gatsby"',
due_date: '2023-10-22',
is_completed: false,
title: 'Read book',
},
]);
});
test('execute with body', async () => {
const { client } = await setup();
const output = await client.callTool({
name: 'postgrestRequest',
arguments: {
method: 'POST',
path: '/todos',
body: {
title: 'Test',
description: 'Test',
due_date: '2023-10-15',
is_completed: false,
},
},
});
const [firstContent] = output.content as any[];
if (!firstContent) {
throw new Error('no content');
}
const [result] = JSON.parse(firstContent.text);
expect(result).toMatchObject({
title: 'Test',
description: 'Test',
due_date: '2023-10-15',
is_completed: false,
});
// Clean up
await client.callTool({
name: 'postgrestRequest',
arguments: {
method: 'DELETE',
path: `/todos?id=eq.${result.id}`,
},
});
});
test('sql-to-rest', async () => {
const { client } = await setup();
const output = await client.callTool({
name: 'sqlToRest',
arguments: {
sql: 'SELECT * FROM todos ORDER BY id ASC',
},
});
const [firstContent] = output.content as any[];
if (!firstContent) {
throw new Error('no content');
}
const result = JSON.parse(firstContent.text);
expect(result).toMatchInlineSnapshot(`
{
"method": "GET",
"path": "/todos?order=id.asc",
}
`);
});
});