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
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ Example client config:

The dev server supports the same [query params as the hosted endpoint](https://supabase.com/docs/guides/ai-tools/mcp#configuration-options). The access token comes from the client's `Authorization` header on each request. Restart the server in your MCP client after each change.

Flags: `--http`, `--port` (default 3111), `--api-url`, `--content-api-url`, `--version`.
Add `--oauth` to sign in with Supabase OAuth in the browser instead. The server then attaches the token to every request itself and the client config needs no `headers`. The session is saved to `~/.supabase/mcp-oauth.json`, next to the Supabase CLI's files (`SUPABASE_HOME` overrides the directory for both). Delete that file to sign out.

Flags: `--http`, `--port` (default 3111), `--oauth`, `--api-url`, `--content-api-url`, `--version`.

To try the HTTP entry from a PR without cloning, run the preview build published by pkg.pr.new:

Expand Down
2 changes: 1 addition & 1 deletion packages/mcp-server-supabase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
},
"dependencies": {
"@mjackson/multipart-parser": "^0.10.1",
"@modelcontextprotocol/client": "catalog:",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note @modelcontextprotocol/client becomes a runtime dep with my changes so --oauth can use the SDK's auth() flow. Platform already has it at the same catalog version as a devDependency of mgmt-api, so the next bump will pull it (plus some small transitives) into the image as install weight. Platform doesn't reach the code that uses it though.

"@modelcontextprotocol/node": "catalog:",
"@supabase/mcp-utils": "workspace:^",
"common-tags": "^1.8.2",
Expand All @@ -68,7 +69,6 @@
"@ai-sdk/anthropic": "catalog:",
"@ai-sdk/mcp": "catalog:",
"@electric-sql/pglite": "^0.2.17",
"@modelcontextprotocol/client": "catalog:",
"@modelcontextprotocol/server": "catalog:",
"@total-typescript/tsconfig": "^1.0.4",
"@types/common-tags": "^1.8.4",
Expand Down
11 changes: 11 additions & 0 deletions packages/mcp-server-supabase/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import packageJson from '../package.json' with { type: 'json' };
import { createSupabaseApiPlatform } from './platform/api-platform.js';
import { createSupabaseMcpServer } from './server.js';
import { startLocalHttpEntry } from './transports/local-http-entry.js';
import { login } from './transports/oauth-client.js';
import { parseList } from './transports/util.js';
import { parseFeatureGroups } from './util.js';

Expand All @@ -23,6 +24,7 @@ async function main() {
['features']: cliFeatures,
['http']: http,
['port']: cliPort,
['oauth']: oauth,
},
} = parseArgs({
options: {
Expand Down Expand Up @@ -56,6 +58,10 @@ async function main() {
type: 'string',
default: '3111',
},
['oauth']: {
type: 'boolean',
default: false,
},
},
});

Expand All @@ -70,10 +76,15 @@ async function main() {
cliContentApiUrl ?? process.env.SUPABASE_CONTENT_API_URL;

if (http) {
// The hosted MCP server's OAuth discovery points at the matching Management API.
const mcpUrl = new URL(apiUrl ?? 'https://api.supabase.com');
mcpUrl.host = mcpUrl.host.replace(/^api\./, 'mcp.');
const accessToken = oauth ? await login(`${mcpUrl.origin}/mcp`) : undefined;
const entry = await startLocalHttpEntry({
port: Number(cliPort),
apiUrl,
contentApiUrl,
accessToken,
});
console.error(`Supabase MCP server listening on ${entry.url}`);
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type {
InputRequiredResult,
VersionNegotiationMode,
} from '@modelcontextprotocol/client';
import { http, passthrough } from 'msw';
import { http, HttpResponse, passthrough } from 'msw';
import type { SetupServer } from 'msw/node';
import { afterEach, beforeEach, describe, expect, test } from 'vitest';

Expand Down Expand Up @@ -141,6 +141,60 @@ describe('startLocalHttpEntry', () => {
});
});

test('rejects a browser Origin', async () => {
const response = await fetch(entry.url, {
method: 'POST',
headers: {
...AUTH_HEADERS,
'content-type': 'application/json',
origin: 'https://evil.example',
},
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/list' }),
});

expect(response.status).toBe(403);
});

test('OAuth mode reads the token source on every request', async () => {
const issued: string[] = [];
const oauthEntry = await startLocalHttpEntry({
port: 0,
apiUrl: API_URL,
accessToken: async () => {
const token = `${ACCESS_TOKEN}-${issued.length + 1}`;
issued.push(token);
return token;
},
log: () => {},
});
cleanups.push(() => oauthEntry.close());
const seen: Array<string | null> = [];
mockServer.use(
http.all(`${new URL(oauthEntry.url).origin}/*`, () => passthrough()),
http.get(`${API_URL}/v1/projects`, ({ request }) => {
seen.push(request.headers.get('authorization'));
return HttpResponse.json([]);
})
);
const client = new Client(
{ name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION },
{ versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } }
);
await client.connect(
new StreamableHTTPClientTransport(new URL(oauthEntry.url))
);
cleanups.push(() => client.close());

await client.callTool({ name: 'list_projects', arguments: {} });
await client.callTool({ name: 'list_projects', arguments: {} });

expect(seen).toHaveLength(2);
expect(seen[0]).not.toBe(seen[1]);
expect(issued.map((token) => `Bearer ${token}`)).toEqual(
expect.arrayContaining(seen)
);
});

test('sends a form-capable client a cost elicitation', async () => {
const client = await connect(
{ pin: MODERN_PROTOCOL_VERSION },
Expand Down
27 changes: 17 additions & 10 deletions packages/mcp-server-supabase/src/transports/local-http-entry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
isJSONRPCRequest,
isSpecType,
localhostAllowedHostnames,
originValidationResponse,
PROTOCOL_VERSION_META_KEY,
} from '@modelcontextprotocol/server';
import { z } from 'zod/v4';
Expand All @@ -24,6 +25,8 @@ export type LocalHttpEntryOptions = {
port: number;
apiUrl?: string;
contentApiUrl?: string;
/** OAuth mode. Supplies the token for every request. */
accessToken?: () => Promise<string>;
log?: (line: string) => void;
};

Expand Down Expand Up @@ -72,25 +75,29 @@ export async function startLocalHttpEntry({
port,
apiUrl,
contentApiUrl,
accessToken: tokenSource,
log = (line) =>
console.error(`[${new Date().toLocaleTimeString('en-GB')}] ${line}`),
}: LocalHttpEntryOptions) {
const requestStateKey = randomBytes(32);
// OAuth tokens refresh, so the principal is a per-process value instead of a token hash.
const processPrincipal = randomBytes(16).toString('hex');
const allowedHostnames = localhostAllowedHostnames();

const server = createServer(
toNodeHandler(
{
fetch: async (request) => {
const rejected = hostHeaderValidationResponse(
request,
allowedHostnames
);
const rejected =
hostHeaderValidationResponse(request, allowedHostnames) ??
originValidationResponse(request, []);
if (rejected) return rejected;

const accessToken = request.headers
.get('authorization')
?.match(/^Bearer (.+)$/i)?.[1];
const accessToken = tokenSource
? await tokenSource()
: request.headers
.get('authorization')
?.match(/^Bearer (.+)$/i)?.[1];
if (!accessToken) {
return Response.json(
{ error: 'missing bearer token' },
Expand Down Expand Up @@ -136,9 +143,9 @@ export async function startLocalHttpEntry({
costConfirmation: {
requestStateKey,
// One process can serve several PATs, so the principal is the token's hash.
principal: createHash('sha256')
.update(accessToken)
.digest('hex'),
principal: tokenSource
? processPrincipal
: createHash('sha256').update(accessToken).digest('hex'),
enabledTools: ['create_project', 'create_branch'],
},
}),
Expand Down
114 changes: 114 additions & 0 deletions packages/mcp-server-supabase/src/transports/oauth-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import { mkdir, readFile, writeFile } from 'node:fs/promises';
import { createServer } from 'node:http';
import { homedir } from 'node:os';
import { dirname, join } from 'node:path';
import {
auth,
type OAuthClientProvider,
type StoredOAuthClientInformation,
type StoredOAuthTokens,
} from '@modelcontextprotocol/client';

// Same directory the Supabase CLI keeps its login in.
export const STORE_PATH = join(
process.env.SUPABASE_HOME ?? join(homedir(), '.supabase'),
'mcp-oauth.json'
);
const REDIRECT_URL = 'http://127.0.0.1:3112/callback';

type Stored = {
client?: StoredOAuthClientInformation;
tokens?: StoredOAuthTokens;
codeVerifier?: string;
expiresAt?: number;
};

async function read(): Promise<Stored> {
try {
return JSON.parse(await readFile(STORE_PATH, 'utf8'));
} catch {
return {};
}
}

async function write(patch: Stored) {
await mkdir(dirname(STORE_PATH), { recursive: true });
await writeFile(STORE_PATH, JSON.stringify({ ...(await read()), ...patch }), {
mode: 0o600,
});
}

const provider: OAuthClientProvider = {
redirectUrl: REDIRECT_URL,
clientMetadata: {
client_name: 'Supabase MCP local dev server',
redirect_uris: [REDIRECT_URL],
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
token_endpoint_auth_method: 'client_secret_post',
},
clientInformation: async () => (await read()).client,
saveClientInformation: (client) => write({ client }),
tokens: async () => (await read()).tokens,
saveTokens: (tokens) =>
write({
tokens,
expiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
}),
saveCodeVerifier: (codeVerifier) => write({ codeVerifier }),
codeVerifier: async () => {
const { codeVerifier } = await read();
if (!codeVerifier) throw new Error('No pending sign-in');
return codeVerifier;
},
invalidateCredentials: (scope) =>
write({
...(scope === 'all' || scope === 'client' ? { client: undefined } : {}),
...(scope === 'all' || scope === 'tokens' ? { tokens: undefined } : {}),
}),
redirectToAuthorization: (url) => {
console.error(`Sign in to Supabase in your browser:\n${url}`);
const openers: Partial<Record<NodeJS.Platform, string>> = {
darwin: 'open',
win32: 'start',
};
const opener = openers[process.platform] ?? 'xdg-open';
spawn(opener, [url.href], { stdio: 'ignore', detached: true }).unref();
},
};

async function waitForCode() {
const server = createServer((req, res) => {
const code = new URL(req.url ?? '/', REDIRECT_URL).searchParams.get('code');
res.end(code ? 'Signed in. You can close this tab.' : 'Missing code.');
if (code) server.emit('code', code);
});
server.listen(3112, '127.0.0.1');
await once(server, 'listening');
const [code] = await once(server, 'code');
server.close();
return String(code);
}

/** Signs in if needed and returns a token getter that refreshes on expiry. */
export async function login(serverUrl: string) {
if ((await auth(provider, { serverUrl })) === 'REDIRECT') {
await auth(provider, { serverUrl, authorizationCode: await waitForCode() });
}
// Refresh tokens are single use, so concurrent callers share one refresh.
let refreshing: Promise<unknown> | undefined;
return async () => {
let { tokens, expiresAt = 0 } = await read();
if (!tokens || expiresAt < Date.now() + 60_000) {
refreshing ??= auth(provider, { serverUrl }).finally(() => {
refreshing = undefined;
});
await refreshing;
({ tokens } = await read());
}
if (!tokens) throw new Error('Not signed in');
return tokens.access_token;
};
}
Loading
Loading