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_workspace is the atomic security definer RPC 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_email is new: auth.users isn't readable by the authenticated role, so inviting by email needs a tiny definer helper. Add a second migration:
supabase migration new add_user_lookup_helper

Paste 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:

  1. As Alice, call create_workspace({ name: "ML Team" }) — note the returned workspace_id.
  2. As Alice, call save_snippet({ workspace_id, title: "RAG eval rubric", body: "...", tags: ["rag"], visibility: "workspace" }).
  3. As Bob (different account, different token), call list_workspaces() — Bob's "ML Team" should NOT appear. Bob can't see Alice's snippet.
  4. As Alice, call invite_to_workspace({ workspace_id, email: "bob@..." }).
  5. As Bob, call list_workspaces() — now ML Team appears.
  6. 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_workspace on a workspace where he's member, not owner. Should get "Only workspace owners can invite members."
  • Anyone calls invite_to_workspace with 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_snippet on 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.