Why these tools matter
Without list_workspaces, Claude has no way to know which workspaces the user belongs to, which means save_snippet becomes a guessing game. Without invite_to_workspace, "share with teammates" only works for people already in the workspace. Without share_snippet, changing visibility requires editing the row directly.
This step closes those gaps.
1. Workspace tools
Create supabase/functions/mcp/tools/workspaces.ts:
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { AuthedUser } from "../auth.ts";
type Ctx = { user: AuthedUser; supabase: SupabaseClient };
const createWorkspaceInput = z.object({
name: z.string().min(1).max(80),
});
const inviteInput = z.object({
workspace_id: z.string().uuid(),
email: z.string().email(),
role: z.enum(["owner", "member"]).default("member"),
});
export function registerWorkspaceTools(server: McpServer, ctx: Ctx) {
const { supabase } = ctx;
// ---------------------------------------------------------------------
// list_workspaces
// ---------------------------------------------------------------------
server.registerTool(
"list_workspaces",
{
description:
"List the workspaces the caller belongs to, with their role. " +
"Use to know which workspace_id to pass to save_snippet, or to " +
"answer 'what teams am I in?'.",
inputSchema: z.object({}),
},
async (): Promise<CallToolResult> => {
const { data, error } = await supabase
.from("workspace_members")
.select("role, workspace:workspace_id(id, name, created_at)")
.order("joined_at", { ascending: true });
if (error) throw new Error(error.message);
const rows = (data ?? []).map((m) => ({
workspace_id: (m.workspace as unknown as { id: string }).id,
name: (m.workspace as unknown as { name: string }).name,
role: m.role,
created_at: (m.workspace as unknown as { created_at: string }).created_at,
}));
return {
content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
};
}
);
// ---------------------------------------------------------------------
// create_workspace
// ---------------------------------------------------------------------
server.registerTool(
"create_workspace",
{
description:
"Create a new workspace and add the caller as its owner. " +
"Use when the user says 'create a team' or 'make a new workspace'.",
inputSchema: createWorkspaceInput,
},
async (
{ name }: z.infer<typeof createWorkspaceInput>,
): Promise<CallToolResult> => {
// One atomic RPC (defined in the step-3 migration). A two-step insert
// from here would trip over RLS: you can't add yourself to
// workspace_members as owner until you're already an owner.
const { data: ws, error } = await supabase
.rpc("create_workspace", { name });
if (error) throw new Error(error.message);
return {
content: [{
type: "text",
text: `Created workspace "${ws.name}" (${ws.id}). You are the owner.`,
}],
};
}
);
// ---------------------------------------------------------------------
// invite_to_workspace
// ---------------------------------------------------------------------
server.registerTool(
"invite_to_workspace",
{
description:
"Invite an existing user to a workspace by their email. " +
"Owners only. Use when the user says 'add Alice to the team' or " +
"'share this workspace with bob@example.com'. The invitee must " +
"already have a Brain Drip account; if not, ask them to sign up first.",
inputSchema: inviteInput,
},
async (
{ workspace_id, email, role }: z.infer<typeof inviteInput>,
): Promise<CallToolResult> => {
// Find the invitee's user id via the SECURITY DEFINER lookup
// (user_id_for_email — added in this step's migration), because
// auth.users isn't readable by the authenticated role.
const { data: invitee, error: lookupErr } = await supabase
.rpc("user_id_for_email", { p_email: email });
if (lookupErr) throw new Error(lookupErr.message);
if (!invitee) throw new Error(`No Brain Drip account for ${email}. Ask them to sign up first.`);
// RLS members_insert requires the caller to be an owner of workspace_id.
const { error: insErr } = await supabase
.from("workspace_members")
.insert({ workspace_id, user_id: invitee, role });
if (insErr) {
// RLS violation surfaces as "new row violates row-level security policy".
// Surface a friendlier message.
if (insErr.code === "42501" || insErr.message.includes("row-level security")) {
throw new Error("Only workspace owners can invite members.");
}
if (insErr.code === "23505") {
throw new Error(`${email} is already a member of this workspace.`);
}
throw new Error(insErr.message);
}
return {
content: [{
type: "text",
text: `Invited ${email} to workspace ${workspace_id} as ${role}.`,
}],
};
}
);
}Two SQL dependencies here, and only one of them is new:
create_workspaceis the atomicsecurity definerRPC we already wrote into the step-3 migration — remember the chicken-and-egg: the two-step insert can't sequence under RLS, so the tool is a one-liner around the RPC.user_id_for_emailis new:auth.usersisn't readable by theauthenticatedrole, so inviting by email needs a tiny definer helper. Add a second migration:
supabase migration new add_user_lookup_helperPaste this SQL:
-- Look up a user_id by email — used by invite_to_workspace.
-- SECURITY DEFINER so the helper can read auth.users without granting the
-- whole authenticated role access to it.
create or replace function public.user_id_for_email(p_email text)
returns uuid language sql security definer
set search_path = public, auth as $$
select id from auth.users where email = lower(p_email) limit 1;
$$;
grant execute on function public.user_id_for_email(text) to authenticated;supabase db push to apply.
One tradeoff worth saying out loud: any signed-in user can call user_id_for_email, which means anyone with an account can probe whether an email address has an account here. For a team tool behind sign-in that's an acceptable trade for the invite UX — but it is an account-existence oracle, so don't reuse this pattern on a public-facing product without rate limits and thought.
2. share_snippet tool
Add this inside registerSnippetTools in supabase/functions/mcp/tools/snippets.ts — the schema goes up top with the other *Input consts, and the registration goes right after save_snippet:
// With the other input schemas at the top of the file:
const shareSnippetInput = z.object({
id: z.string().uuid(),
visibility: z.enum(["private", "workspace", "public"]),
});
// Inside registerSnippetTools, after the save_snippet registration:
server.registerTool(
"share_snippet",
{
description:
"Change a snippet's visibility between private, workspace, and public. " +
"Use when the user says 'share this with the team' or 'make this private'.",
inputSchema: shareSnippetInput,
},
async (
{ id, visibility }: z.infer<typeof shareSnippetInput>,
): Promise<CallToolResult> => {
// Look the snippet up first so "no such snippet" doesn't get conflated
// with the odd-but-legal case handled below.
const { data: before, error: readErr } = await supabase
.from("snippets")
.select("id, title, created_by")
.eq("id", id)
.maybeSingle();
if (readErr) throw new Error(readErr.message);
if (!before) throw new Error("snippet not found or not visible to you");
const { data, error } = await supabase
.from("snippets")
.update({ visibility })
.eq("id", id)
.select("id, title, visibility")
.maybeSingle();
if (error) throw new Error(error.message);
if (!data) {
// UPDATE ... RETURNING is filtered by the SELECT policy too. A
// workspace owner who just set someone else's snippet to `private`
// has successfully updated a row they can no longer see. Re-check
// instead of guessing.
const { data: after } = await supabase
.from("snippets")
.select("id")
.eq("id", id)
.maybeSingle();
if (!after && visibility === "private") {
return {
content: [{
type: "text",
text:
`"${before.title}" is now private. Only its creator can see ` +
`it — it has left your view.`,
}],
};
}
throw new Error("you can't change this snippet's visibility");
}
return {
content: [{
type: "text",
text: `"${data.title}" is now ${data.visibility}.`,
}],
};
}
);The RLS snippets_update policy already gates this on created_by = auth.uid() OR public.is_workspace_owner(workspace_id). Snippet authors and workspace owners can flip visibility; nobody else can.
The null-handling dance in the middle deserves a sentence, because it's a genuine RLS subtlety: the row an UPDATE ... RETURNING gives back still has to pass the select policy. So when a workspace owner sets someone else's snippet to private, the update succeeds — and then the returned row is filtered out, because a private snippet is only visible to its creator. Without the re-check, the tool would report failure for an operation that worked. Cheap lesson here; expensive one in production.
3. Wire the new tool set in
In supabase/functions/mcp/index.ts:
import { registerSnippetTools } from "./tools/snippets.ts";
import { registerWorkspaceTools } from "./tools/workspaces.ts";
// ...
app.all("/", requireAuth, async (c) => {
// ...existing...
registerSnippetTools(server, { user, supabase });
registerWorkspaceTools(server, { user, supabase });
// ...rest...
});4. Test sharing end-to-end
Walk through this in the MCP Inspector with two different Supabase accounts:
- As Alice, call
create_workspace({ name: "ML Team" })— note the returnedworkspace_id. - As Alice, call
save_snippet({ workspace_id, title: "RAG eval rubric", body: "...", tags: ["rag"], visibility: "workspace" }). - As Bob (different account, different token), call
list_workspaces()— Bob's "ML Team" should NOT appear. Bob can't see Alice's snippet. - As Alice, call
invite_to_workspace({ workspace_id, email: "bob@..." }). - As Bob, call
list_workspaces()— now ML Team appears. - As Bob, call
list_snippets({ workspace_id })— Alice's rubric appears.
If all six steps behave as described, the auth + RLS + tool chain is correct.
5. Test the failure modes
- Bob calls
invite_to_workspaceon a workspace where he'smember, notowner. Should get "Only workspace owners can invite members." - Anyone calls
invite_to_workspacewith an email that's not a registered user. Should get "No Brain Drip account for ___. Ask them to sign up first." - A user calls
share_snippeton someone else's snippet in a workspace they don't own. Should get "you can't change this snippet's visibility."
All three should produce clean error text, not stack traces.
6. What about scopes?
OAuth 2.1 supports per-token scopes. We're not using them here — every authenticated user can call every tool, with row-level security determining what data they see. That's intentional:
- Granular permissions via RLS are stronger than scopes for data access; scopes typically gate capabilities (which tools are callable).
- Adding scopes later is additive; deciding "this OAuth client can only read, not write" is straightforward to bolt on if a future use case requires it.
If you want to add a "read-only" client (say, a public-facing snippet browser), check ctx.user.clientId in the relevant tool handlers and reject mutations for that client_id — it's the same claim the ALLOWED_CLIENT_IDS allowlist from step 6 reads.
The tool surface is complete. Step 10 wires up MCP resources (data Claude can browse without invoking a tool), and step 11 deploys it for real and connects Claude.