DEV Community

Cover image for I thought building an MCP server was the hard part. It wasn't.
Adrin T Paul
Adrin T Paul

Posted on

I thought building an MCP server was the hard part. It wasn't.

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);
Enter fullscreen mode Exit fullscreen mode

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
);
Enter fullscreen mode Exit fullscreen mode

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("");
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

The transport setup is three lines:

const server    = createServer(apiKey);
const transport = new StdioServerTransport();
await server.connect(transport);
Enter fullscreen mode Exit fullscreen mode

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"]
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

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

Top comments (1)

Collapse
 
harjjotsinghh profile image
Harjot Singh

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.