Skip to content

Commit e30a8be

Browse files
authored
Merge pull request #63 from supabase-community/feat/project-scoped
feat: project scoped server option
2 parents ae7dcde + 0981afd commit e30a8be

File tree

13 files changed

+1460
-881
lines changed

13 files changed

+1460
-881
lines changed

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ Next, configure your MCP client (such as Cursor) to use this server. Most MCP cl
4646

4747
Replace `<personal-access-token>` with the token you created in step 1. Alternatively you can omit `--access-token` and instead set the `SUPABASE_ACCESS_TOKEN` environment variable to your personal access token (you will need to restart your MCP client after setting this). This allows you to keep your token out of version control if you plan on committing this configuration to a repository.
4848

49+
The following additional options are available:
50+
51+
- `--project-ref`: Used to scope the server to a specific project. See [project scoped mode](#project-scoped-mode).
52+
- `--read-only`: Used to restrict the server to read-only queries. See [read-only mode](#read-only-mode).
53+
4954
If you are on Windows, you will need to [prefix the command](#windows). If your MCP client doesn't accept JSON, the direct CLI command is:
5055

5156
```shell
@@ -111,6 +116,18 @@ Make sure Node.js is available in your system `PATH` environment variable. If yo
111116

112117
3. Restart your MCP client.
113118

119+
### Project scoped mode
120+
121+
By default, the MCP server will have access to all organizations and projects in your Supabase account. If you want to restrict the server to a specific project, you can set the `--project-ref` flag on the CLI command:
122+
123+
```shell
124+
npx -y @supabase/mcp-server-supabase@latest --access-token=<personal-access-token> --project-ref=<project-ref>
125+
```
126+
127+
Replace `<project-ref>` with the ID of your project. You can find this under **Project ID** in your Supabase [project settings](https://supabase.com/dashboard/project/_/settings/general).
128+
129+
After scoping the server to a project, [account-level](#project-management) tools like `list_projects` and `list_organizations` will no longer be available. The server will only have access to the specified project and its resources.
130+
114131
### Read-only mode
115132

116133
If you wish to restrict the Supabase MCP server to read-only queries, set the `--read-only` flag on the CLI command:
@@ -129,6 +146,8 @@ The following Supabase tools are available to the LLM:
129146

130147
#### Project Management
131148

149+
_**Note:** these tools will be unavailable if the server is [scoped to a project](#project-scoped-mode)._
150+
132151
- `list_projects`: Lists all Supabase projects for the user.
133152
- `get_project`: Gets details for a project.
134153
- `create_project`: Creates a new Supabase project.

packages/mcp-server-supabase/src/server.test.ts

Lines changed: 156 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,15 @@ beforeEach(async () => {
3434

3535
type SetupOptions = {
3636
accessToken?: string;
37+
projectId?: string;
3738
readOnly?: boolean;
3839
};
3940

4041
/**
4142
* Sets up an MCP client and server for testing.
4243
*/
4344
async function setup(options: SetupOptions = {}) {
44-
const { accessToken = ACCESS_TOKEN, readOnly } = options;
45+
const { accessToken = ACCESS_TOKEN, projectId, readOnly } = options;
4546
const clientTransport = new StreamTransport();
4647
const serverTransport = new StreamTransport();
4748

@@ -63,6 +64,7 @@ async function setup(options: SetupOptions = {}) {
6364
apiUrl: API_URL,
6465
accessToken,
6566
},
67+
projectId,
6668
readOnly,
6769
});
6870

@@ -348,7 +350,6 @@ describe('tools', () => {
348350
name: 'New Project',
349351
region: 'us-east-1',
350352
organization_id: freeOrg.id,
351-
db_pass: 'dummy-password',
352353
confirm_cost_id,
353354
};
354355

@@ -357,7 +358,7 @@ describe('tools', () => {
357358
arguments: newProject,
358359
});
359360

360-
const { db_pass, confirm_cost_id: _, ...projectInfo } = newProject;
361+
const { confirm_cost_id: _, ...projectInfo } = newProject;
361362

362363
expect(result).toEqual({
363364
...projectInfo,
@@ -390,7 +391,6 @@ describe('tools', () => {
390391
const newProject = {
391392
name: 'New Project',
392393
organization_id: freeOrg.id,
393-
db_pass: 'dummy-password',
394394
confirm_cost_id,
395395
};
396396

@@ -399,7 +399,7 @@ describe('tools', () => {
399399
arguments: newProject,
400400
});
401401

402-
const { db_pass, confirm_cost_id: _, ...projectInfo } = newProject;
402+
const { confirm_cost_id: _, ...projectInfo } = newProject;
403403

404404
expect(result).toEqual({
405405
...projectInfo,
@@ -425,7 +425,6 @@ describe('tools', () => {
425425
name: 'New Project',
426426
region: 'us-east-1',
427427
organization_id: org.id,
428-
db_pass: 'dummy-password',
429428
};
430429

431430
const createProjectPromise = callTool({
@@ -1911,3 +1910,154 @@ describe('tools', () => {
19111910
}
19121911
});
19131912
});
1913+
1914+
describe('project scoped tools', () => {
1915+
test('no account level tools should exist', async () => {
1916+
const org = await createOrganization({
1917+
name: 'My Org',
1918+
plan: 'free',
1919+
allowed_release_channels: ['ga'],
1920+
});
1921+
1922+
const project = await createProject({
1923+
name: 'Project 1',
1924+
region: 'us-east-1',
1925+
organization_id: org.id,
1926+
});
1927+
1928+
const { client } = await setup({ projectId: project.id });
1929+
1930+
const result = await client.listTools();
1931+
1932+
const accountLevelToolNames = [
1933+
'list_organizations',
1934+
'get_organization',
1935+
'list_projects',
1936+
'get_project',
1937+
'get_cost',
1938+
'confirm_cost',
1939+
'create_project',
1940+
'pause_project',
1941+
'restore_project',
1942+
];
1943+
1944+
const toolNames = result.tools.map((tool) => tool.name);
1945+
1946+
for (const accountLevelToolName of accountLevelToolNames) {
1947+
expect(
1948+
toolNames,
1949+
`tool ${accountLevelToolName} should not be available in project scope`
1950+
).not.toContain(accountLevelToolName);
1951+
}
1952+
});
1953+
1954+
test('no tool should accept a project_id', async () => {
1955+
const org = await createOrganization({
1956+
name: 'My Org',
1957+
plan: 'free',
1958+
allowed_release_channels: ['ga'],
1959+
});
1960+
1961+
const project = await createProject({
1962+
name: 'Project 1',
1963+
region: 'us-east-1',
1964+
organization_id: org.id,
1965+
});
1966+
1967+
const { client } = await setup({ projectId: project.id });
1968+
1969+
const result = await client.listTools();
1970+
1971+
expect(result.tools).toBeDefined();
1972+
expect(Array.isArray(result.tools)).toBe(true);
1973+
1974+
for (const tool of result.tools) {
1975+
const schemaProperties = tool.inputSchema.properties ?? {};
1976+
expect(
1977+
'project_id' in schemaProperties,
1978+
`tool ${tool.name} should not accept a project_id`
1979+
).toBe(false);
1980+
}
1981+
});
1982+
1983+
test('invalid project ID should throw an error', async () => {
1984+
const { callTool } = await setup({ projectId: 'invalid-project-id' });
1985+
1986+
const listTablesPromise = callTool({
1987+
name: 'list_tables',
1988+
arguments: {
1989+
schemas: ['public'],
1990+
},
1991+
});
1992+
1993+
await expect(listTablesPromise).rejects.toThrow('Project not found');
1994+
});
1995+
1996+
test('passing project_id to a tool should throw an error', async () => {
1997+
const org = await createOrganization({
1998+
name: 'My Org',
1999+
plan: 'free',
2000+
allowed_release_channels: ['ga'],
2001+
});
2002+
2003+
const project = await createProject({
2004+
name: 'Project 1',
2005+
region: 'us-east-1',
2006+
organization_id: org.id,
2007+
});
2008+
project.status = 'ACTIVE_HEALTHY';
2009+
2010+
const { callTool } = await setup({ projectId: project.id });
2011+
2012+
const listTablesPromise = callTool({
2013+
name: 'list_tables',
2014+
arguments: {
2015+
project_id: 'my-project-id',
2016+
schemas: ['public'],
2017+
},
2018+
});
2019+
2020+
await expect(listTablesPromise).rejects.toThrow('Unrecognized key');
2021+
});
2022+
2023+
test('listing tables implicitly uses the scoped project_id', async () => {
2024+
const org = await createOrganization({
2025+
name: 'My Org',
2026+
plan: 'free',
2027+
allowed_release_channels: ['ga'],
2028+
});
2029+
2030+
const project = await createProject({
2031+
name: 'Project 1',
2032+
region: 'us-east-1',
2033+
organization_id: org.id,
2034+
});
2035+
project.status = 'ACTIVE_HEALTHY';
2036+
2037+
project.db
2038+
.sql`create table test (id integer generated always as identity primary key)`;
2039+
2040+
const { callTool } = await setup({ projectId: project.id });
2041+
2042+
const result = await callTool({
2043+
name: 'list_tables',
2044+
arguments: {
2045+
schemas: ['public'],
2046+
},
2047+
});
2048+
2049+
expect(result).toEqual([
2050+
expect.objectContaining({
2051+
name: 'test',
2052+
schema: 'public',
2053+
columns: [
2054+
expect.objectContaining({
2055+
name: 'id',
2056+
is_identity: true,
2057+
is_generated: false,
2058+
}),
2059+
],
2060+
}),
2061+
]);
2062+
});
2063+
});

0 commit comments

Comments
 (0)