Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,25 @@ const tools = await mcpClient.tools({
});
```

The package also exports `createToolAccessHints()` and `createOAuthScopeHints()` for integrations that need to reason about the minimum Management API access surface implied by a given MCP configuration:

```ts
import { createOAuthScopeHints } from '@supabase/mcp-server-supabase';

createOAuthScopeHints({
features: ['docs'],
});
// => []

createOAuthScopeHints({
features: ['database', 'docs'],
readOnly: true,
});
// => ['database:read']
```

By default, `createOAuthScopeHints()` only returns scope families documented in Supabase's public OAuth scope guide. Pass `includeInferred: true` to also include best-effort hints for Management API surfaces that are used by the MCP server but are not currently listed in the public scope table.

> [!NOTE]
> This server does not send `structuredContent` in MCP tool results. AI SDK falls back to parsing JSON from `content` text.

Expand Down
10 changes: 10 additions & 0 deletions packages/mcp-server-supabase/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,13 @@ export {
createToolSchemas,
supabaseMcpToolSchemas,
} from './tools/tool-schemas.js';
export {
createOAuthScopeHints,
createToolAccessHints,
supabaseMcpToolAccessHints,
type OAuthScopeHint,
type OAuthScopeHintSource,
type OAuthScopeLevel,
type OAuthScopeResource,
type ToolAccessEntry,
} from './tools/tool-access.js';
86 changes: 86 additions & 0 deletions packages/mcp-server-supabase/src/tools/tool-access.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { describe, expect, test } from 'vitest';
import { supabaseMcpToolSchemas } from './tool-schemas.js';
import {
createOAuthScopeHints,
createToolAccessHints,
supabaseMcpToolAccessHints,
} from './tool-access.js';

describe('tool access hints', () => {
test('covers every published tool schema', () => {
expect(Object.keys(supabaseMcpToolAccessHints).sort()).toEqual(
Object.keys(supabaseMcpToolSchemas).sort()
);
});

test('docs-only configuration requires no OAuth scope hints', () => {
expect(createOAuthScopeHints({ features: ['docs'] })).toEqual([]);
});

test('read-only database mode downgrades execute_sql to database:read', () => {
expect(
createOAuthScopeHints({
features: ['database'],
readOnly: true,
})
).toEqual(['database:read']);
});

test('project-scoped mode excludes account-level requirements', () => {
expect(
createOAuthScopeHints({
features: ['account', 'database'],
projectScoped: true,
})
).toEqual(['database:read', 'database:write']);
});

test('development tools only add the scopes they actually need', () => {
expect(
createOAuthScopeHints({
features: ['account', 'development'],
})
).toEqual([
'database:read',
'organizations:read',
'projects:read',
'projects:write',
'secrets:read',
]);
});

test('inferred scope families are opt-in', () => {
expect(
createOAuthScopeHints({
features: ['debugging', 'storage'],
})
).toEqual([]);

expect(
createOAuthScopeHints({
features: ['debugging', 'storage'],
includeInferred: true,
})
).toEqual([
'advisors:read',
'analytics:read',
'storage:read',
'storage:write',
]);
});

test('tool access filtering mirrors feature and read-only filtering', () => {
const hints = createToolAccessHints({
features: ['database', 'docs'],
readOnly: true,
});

expect(Object.keys(hints).sort()).toEqual([
'execute_sql',
'list_extensions',
'list_migrations',
'list_tables',
'search_docs',
]);
});
});
269 changes: 269 additions & 0 deletions packages/mcp-server-supabase/src/tools/tool-access.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,269 @@
import { CURRENT_FEATURE_GROUPS, type FeatureGroup } from '../types.js';
import { supabaseMcpToolSchemas } from './tool-schemas.js';

export type OAuthScopeHintSource = 'documented' | 'inferred';

export type OAuthScopeResource =
| 'organizations'
| 'projects'
| 'database'
| 'edge_functions'
| 'environment'
| 'secrets'
| 'analytics'
| 'advisors'
| 'storage';

export type OAuthScopeLevel = 'read' | 'write';

export type OAuthScopeHint = {
resource: OAuthScopeResource;
level: OAuthScopeLevel;
source: OAuthScopeHintSource;
};

export type ToolAccessEntry = {
featureGroup: FeatureGroup;
/**
* Best-effort minimum requirements for the tool in normal mode.
*
* For documented Management API scope families, `source` is `documented`.
* For MCP surfaces whose scope family is not currently listed in the public
* OAuth docs, `source` is `inferred` from the Management API endpoint family.
*/
requirements: readonly OAuthScopeHint[];
/**
* Optional override for tools that remain available in read-only mode but
* adapt their behavior to a less privileged access pattern.
*/
readOnlyRequirements?: readonly OAuthScopeHint[];
};

type ToolName = keyof typeof supabaseMcpToolSchemas;

const documented = (
resource: Exclude<OAuthScopeResource, 'analytics' | 'advisors' | 'storage'>,
level: OAuthScopeLevel
): OAuthScopeHint => ({
resource,
level,
source: 'documented',
});

const inferred = (
resource: Extract<OAuthScopeResource, 'analytics' | 'advisors' | 'storage'>,
level: OAuthScopeLevel
): OAuthScopeHint => ({
resource,
level,
source: 'inferred',
});

export const supabaseMcpToolAccessHints = {
search_docs: {
featureGroup: 'docs',
requirements: [],
},
list_organizations: {
featureGroup: 'account',
requirements: [documented('organizations', 'read')],
},
get_organization: {
featureGroup: 'account',
requirements: [documented('organizations', 'read')],
},
list_projects: {
featureGroup: 'account',
requirements: [documented('projects', 'read')],
},
get_project: {
featureGroup: 'account',
requirements: [documented('projects', 'read')],
},
get_cost: {
featureGroup: 'account',
requirements: [
documented('organizations', 'read'),
documented('projects', 'read'),
],
},
confirm_cost: {
featureGroup: 'account',
requirements: [],
},
create_project: {
featureGroup: 'account',
requirements: [
documented('organizations', 'read'),
documented('projects', 'read'),
documented('projects', 'write'),
],
},
pause_project: {
featureGroup: 'account',
requirements: [documented('projects', 'write')],
},
restore_project: {
featureGroup: 'account',
requirements: [documented('projects', 'write')],
},
list_tables: {
featureGroup: 'database',
requirements: [documented('database', 'read')],
},
list_extensions: {
featureGroup: 'database',
requirements: [documented('database', 'read')],
},
list_migrations: {
featureGroup: 'database',
requirements: [documented('database', 'read')],
},
apply_migration: {
featureGroup: 'database',
requirements: [documented('database', 'write')],
},
execute_sql: {
featureGroup: 'database',
requirements: [documented('database', 'write')],
readOnlyRequirements: [documented('database', 'read')],
},
get_logs: {
featureGroup: 'debugging',
requirements: [inferred('analytics', 'read')],
},
get_advisors: {
featureGroup: 'debugging',
requirements: [inferred('advisors', 'read')],
},
get_project_url: {
featureGroup: 'development',
requirements: [],
},
get_publishable_keys: {
featureGroup: 'development',
requirements: [documented('secrets', 'read')],
},
generate_typescript_types: {
featureGroup: 'development',
requirements: [documented('database', 'read')],
},
list_edge_functions: {
featureGroup: 'functions',
requirements: [documented('edge_functions', 'read')],
},
get_edge_function: {
featureGroup: 'functions',
requirements: [documented('edge_functions', 'read')],
},
deploy_edge_function: {
featureGroup: 'functions',
requirements: [documented('edge_functions', 'write')],
},
create_branch: {
featureGroup: 'branching',
requirements: [documented('environment', 'write')],
},
list_branches: {
featureGroup: 'branching',
requirements: [documented('environment', 'read')],
},
delete_branch: {
featureGroup: 'branching',
requirements: [documented('environment', 'write')],
},
merge_branch: {
featureGroup: 'branching',
requirements: [documented('environment', 'write')],
},
reset_branch: {
featureGroup: 'branching',
requirements: [documented('environment', 'write')],
},
rebase_branch: {
featureGroup: 'branching',
requirements: [documented('environment', 'write')],
},
list_storage_buckets: {
featureGroup: 'storage',
requirements: [inferred('storage', 'read')],
},
get_storage_config: {
featureGroup: 'storage',
requirements: [inferred('storage', 'read')],
},
update_storage_config: {
featureGroup: 'storage',
requirements: [inferred('storage', 'write')],
},
} as const satisfies Record<ToolName, ToolAccessEntry>;

const writeToolSet = new Set(
Object.entries(supabaseMcpToolSchemas)
.filter(
([, entry]) =>
entry.annotations.readOnlyHint === false &&
entry.readOnlyBehavior !== 'adapt'
)
.map(([name]) => name)
);

export type CreateToolAccessHintsOptions = {
features?: readonly FeatureGroup[];
projectScoped?: boolean;
readOnly?: boolean;
};

export function createToolAccessHints(options: CreateToolAccessHintsOptions = {}) {
const enabledFeatures = new Set(options.features ?? CURRENT_FEATURE_GROUPS);
const projectScoped = options.projectScoped ?? false;
const readOnly = options.readOnly ?? false;

const result: Partial<Record<ToolName, ToolAccessEntry>> = {};

for (const [toolName, entry] of Object.entries(supabaseMcpToolAccessHints) as [
ToolName,
ToolAccessEntry,
][]) {
if (!enabledFeatures.has(entry.featureGroup)) continue;
if (projectScoped && entry.featureGroup === 'account') continue;
if (readOnly && writeToolSet.has(toolName)) continue;

result[toolName] = entry;
}

return result;
}

function toScopeString({ resource, level }: OAuthScopeHint) {
return `${resource}:${level}`;
}

export function createOAuthScopeHints(
options: CreateToolAccessHintsOptions & {
includeInferred?: boolean;
} = {}
) {
const includeInferred = options.includeInferred ?? false;
const toolHints = createToolAccessHints(options);

const scopes = new Map<string, OAuthScopeHint>();

for (const entry of Object.values(toolHints)) {
const requirements =
options.readOnly && entry.readOnlyRequirements
? entry.readOnlyRequirements
: entry.requirements;

for (const requirement of requirements) {
if (!includeInferred && requirement.source === 'inferred') continue;
scopes.set(toScopeString(requirement), requirement);
}
}

return [...scopes.values()]
.sort((left, right) =>
toScopeString(left).localeCompare(toScopeString(right))
)
.map(toScopeString);
}