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 { Server } from "@modelcontextprotocol/sdk/server/index.js";
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { AuthedUser } from "../auth.ts";
 
type Ctx = { user: AuthedUser; supabase: SupabaseClient };
 
export function registerWorkspaceTools(server: Server, ctx: Ctx) {
  const { user, supabase } = ctx;
 
  // ---------------------------------------------------------------------
  // list_workspaces
  // ---------------------------------------------------------------------
  server.tool(
    "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 () => {
      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 { id: string }).id,
        name:         (m.workspace as { name: string }).name,
        role:         m.role,
        created_at:   (m.workspace as { created_at: string }).created_at,
      }));
 
      return {
        content: [{ type: "text", text: JSON.stringify(rows, null, 2) }],
      };
    }
  );
 
  // ---------------------------------------------------------------------
  // create_workspace
  // ---------------------------------------------------------------------
  server.tool(
    "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: z.object({
        name: z.string().min(1).max(80),
      }),
    },
    async ({ name }) => {
      // 1. Create the workspace (RLS workspaces_insert requires created_by = auth.uid())
      const { data: ws, error: wsErr } = await supabase
        .from("workspaces")
        .insert({ name, created_by: user.sub })
        .select("id, name, created_at")
        .single();
 
      if (wsErr) throw new Error(wsErr.message);
 
      // 2. Add the creator as an owner
      const { error: memErr } = await supabase
        .from("workspace_members")
        .insert({ workspace_id: ws.id, user_id: user.sub, role: "owner" });
 
      if (memErr) throw new Error(memErr.message);
 
      return {
        content: [{
          type: "text",
          text: `Created workspace "${ws.name}" (${ws.id}). You are the owner.`,
        }],
      };
    }
  );
 
  // ---------------------------------------------------------------------
  // invite_to_workspace
  // ---------------------------------------------------------------------
  server.tool(
    "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: z.object({
        workspace_id: z.string().uuid(),
        email: z.string().email(),
        role:  z.enum(["owner", "member"]).default("member"),
      }),
    },
    async ({ workspace_id, email, role }) => {
      // Find the invitee's user id. We can read auth.users only via the
      // service_role; instead, we use a public.profiles table or a
      // Supabase RPC. Simplest path here: a SECURITY DEFINER function
      // that looks up the user by email.
      //
      // Add this SQL once, in a new migration:
      //
      //   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;
 
      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}.`,
        }],
      };
    }
  );
}

The invite_to_workspace tool needs one tiny SQL helper because auth.users isn't directly readable by the authenticated role. 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.

2. share_snippet tool

Add this to supabase/functions/mcp/tools/snippets.ts (or split into its own file — your call):

server.tool(
  "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: z.object({
      id: z.string().uuid(),
      visibility: z.enum(["private", "workspace", "public"]),
    }),
  },
  async ({ id, visibility }) => {
    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)  throw new Error("snippet not found or you can't change its 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.

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 "snippet not found or you can't change its 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 c.get('user').clientId in the relevant tool handlers and reject mutations for that client_id.


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.