Last month I published promptbuilder-mcp — an MCP server that lets Claude Desktop and Cursor pull prompt components directly from Prompt Builder, a component-based prompt IDE I've been building.
The MCP protocol side took a weekend. The auth problem took much longer and taught me more. This is what I learned.
What I was building
Prompt Builder lets you assemble prompts from reusable components — personas, protocols, formats, templates — stored in a Supabase database. Users can browse a public vault, create private components, and compile everything into structured prompts.
The idea with the MCP server was simple: instead of opening the browser, a user should be able to type "fetch my personas from Prompt Builder" inside Claude Desktop and get them back as tool results.
Simple idea. The implementation had one problem I didn't see coming.
The assumption that broke everything
My initial mental model was wrong.
I assumed the MCP server would somehow inherit the user's existing session — that because the user was logged into Prompt Builder in their browser, the MCP server running on their machine could just... talk to the API and get their data.
That's not how it works at all.
The MCP server is a separate process running via stdio on the user's machine. It has no browser. It has no cookies. It has no session. When it makes an HTTP request to my API, it's completely unauthenticated by default.
This became obvious immediately when I tested it. The server connected fine. Tool registration worked. But every data request came back empty — because all my Supabase tables had Row Level Security enabled, and every query was scoped to auth.uid().
-- This policy meant unauthenticated requests got zero rows
CREATE POLICY "Users can read own components"
ON components FOR SELECT
USING (auth.uid() = user_id OR is_public = true);
Public components came through fine. Private ones — the user's own vault — returned nothing. No error. Just empty arrays. That was actually worse than an error because it took me a while to understand why.
Why I couldn't use Supabase Auth directly
The obvious fix seemed to be: pass the user's Supabase JWT into the MCP server config.
The problem with that is Supabase JWTs expire. A user would configure their MCP server with a token, it would work for an hour, then silently stop returning private data again. That's a terrible experience. You'd also be asking users to paste a raw JWT into a config file, which is not something you want to encourage.
I needed something purpose-built for this use case — long-lived, revocable, read-only, and not tied to the session system.
Building the API key system
I built a dedicated api_keys table in Supabase:
CREATE TABLE api_keys (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL,
key_hash text UNIQUE NOT NULL,
key_prefix text NOT NULL,
scopes text[] DEFAULT '{}',
last_used_at timestamptz,
created_at timestamptz DEFAULT now(),
expires_at timestamptz,
is_active boolean DEFAULT true
);
The key format is pb_ followed by 32 random hex characters:
// utils/apiKeyHelper.js
export function generateRawKey() {
const array = new Uint8Array(16);
crypto.getRandomValues(array);
return "pb_" + Array.from(array)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
export async function hashKey(rawKey) {
const encoder = new TextEncoder();
const data = encoder.encode(rawKey);
const hashBuf = await crypto.subtle.digest("SHA-256", data);
return Array.from(new Uint8Array(hashBuf))
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
The raw key is shown to the user exactly once and never stored. Only the SHA-256 hash lives in the database. On every MCP request, I hash the incoming Bearer token and compare it against the stored hash.
// utils/apiKeyHelper.js
export async function verifyApiKey(rawKey) {
const hash = await hashKey(rawKey);
const { data, error } = await supabase
.from("api_keys")
.select("user_id, scopes, is_active")
.eq("key_hash", hash)
.eq("is_active", true)
.single();
if (error || !data) return null;
// Update last_used_at without blocking the response
supabase
.from("api_keys")
.update({ last_used_at: new Date().toISOString() })
.eq("key_hash", hash);
return data;
}
Scopes in v1 are components:read and packs:read — read-only access only. Creating or editing components requires a logged-in browser session, not an API key.
The MCP endpoints
With auth solved, the MCP endpoints are straightforward. All four are GET routes that verify the Bearer token, resolve the user_id, and query Supabase with that context:
GET /api/mcp/components — list/search by type and query
GET /api/mcp/components/[id] — fetch single component
GET /api/mcp/packs — list/search by category
GET /api/mcp/packs/[id] — fetch pack with all components resolved
The packs/[id] endpoint is worth calling out. A pack contains an array of component IDs. Rather than making the MCP client do N follow-up requests to resolve each component, this endpoint resolves everything in a single call and returns the full component content inline. That matters for MCP because each tool call has latency — fewer calls is always better.
The MCP server itself
The server uses the @modelcontextprotocol/sdk with stdio transport. Five tools, one HTTP client, straightforward registration:
// src/index.js
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { createClient } from "./client.js";
export function createServer(apiKey) {
const server = new McpServer({
name: "promptbuilder",
version: "1.0.0",
});
const client = createClient(apiKey);
server.tool(
"list_components",
"List prompt components by type with optional name search",
{
type: z.enum(["persona", "protocol", "format", "template"]).optional(),
query: z.string().optional(),
},
async ({ type, query }) => {
const data = await client.listComponents({ type, query });
return {
content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
};
}
);
// ... get_component, search_components, list_packs, get_pack
return server;
}
The transport setup is three lines:
const server = createServer(apiKey);
const transport = new StdioServerTransport();
await server.connect(transport);
What it looks like from inside Claude Desktop
This is the part that made it feel real.
After a user generates an API key in /settings and adds the config to claude_desktop_config.json:
{
"mcpServers": {
"promptbuilder": {
"command": "npx",
"args": ["-y", "promptbuilder-mcp", "--key", "pb_your_key_here"]
}
}
}
They can then type inside Claude Desktop:
"Fetch my personas from Prompt Builder and write me a customer support response using the Customer Support Specialist persona."
Claude calls list_components with type: "persona", gets the user's specific personas back, and uses the content directly in its response. The user never left the chat window.
That's the whole point of MCP — your tools become Claude's tools.
What I'd do differently
Start with auth design, not protocol implementation. I spent time on the MCP SDK before I'd thought through how a stateless process authenticates against a user-scoped database. Get the auth model right first.
Hash on the server, not the client. Early versions hashed the key in the browser before sending. That's wrong — hash on the server when verifying, compare against the stored hash. Simpler and correct.
Return everything in one call where possible. The get_pack endpoint that resolves component IDs inline was an afterthought. It should have been the first thing I built because multiple round-trips in MCP are slow.
Links
- Live: promptbuilder
- npm: npmjs.com/package/promptbuilder-mcp
- GitHub: github.com/themechbro/promptbuilder
- MCP server repo: github.com/themechbro/promptbuilder-mcp If you're building an MCP server that needs to talk to user-scoped data, the auth design is where you should spend your time first.
Top comments (1)
i found it interesting how you thought the MCP protocol would be the challenging part, but it turned out to be the auth issues that really tested you. building something like that can be a wild ride. if you're ever looking to spin up an app quickly, moonshift can get you a next.js + postgres + auth setup deployed in about 7 minutes. let me know if you want to run a free build.