Skip to main content
Back to blog

Building a Typed MCP Server in TypeScript with Stripe and Supabase

Build a typed Model Context Protocol server in TypeScript that exposes Supabase and Stripe to an AI agent, with zod-validated tools and read vs write safety.

mcptypescriptstripesupabaseai-agentsmodel-context-protocolzod
By Josh Elberg
Share:

Most Model Context Protocol examples expose a toy: an echo tool, a dice roller, a weather lookup against a public API. Those teach the wiring but skip the part that makes MCP useful, which is handing an AI agent a controlled door into systems you already run. This tutorial builds that door. By the end you will have a typed MCP server that lets an agent look up a customer in Supabase, list that customer's recent Stripe charges, and create a Stripe payment link, all over standard input and output so it plugs straight into Claude Desktop.

MCP is a protocol that standardizes how an AI application talks to external tools and data. Your server advertises a set of tools, each with a name, a description, and an input schema. The client (an agent) reads those schemas, decides when to call a tool, and gets a structured result back. Typed tools matter here because the schema is the contract the model reads. A vague schema produces vague calls and bad arguments. A tight zod schema means the model knows exactly what a customer email looks like, and your handler never has to defend against a string where it expected a number.

What you will build

Three tools, each backed by a real system:

  • lookup_customer reads a row from a Supabase table by email. Read only.
  • list_recent_charges lists a customer's recent Stripe charges. Read only.
  • create_payment_link creates a Stripe payment link for a given price. This one writes, so it gets extra care.

Prerequisites

  • Node.js 20 or newer. The SDK requires it, and the import paths below assume native ESM.
  • A Supabase project with a customers table that has at least id, email, and stripe_customer_id columns.
  • A Stripe account in test mode, plus a Price ID for the payment link tool.
  • An MCP client. Claude Desktop is the easiest to point at a local stdio server.

You will need three secrets. Put placeholders in a .env file and never commit real keys:

SUPABASE_URL=https://YOUR_PROJECT.supabase.co
SUPABASE_SERVICE_ROLE_KEY=YOUR_SERVICE_ROLE_KEY
STRIPE_SECRET_KEY=sk_test_YOUR_KEY

The Supabase service role key bypasses row level security, which is fine for a server process you control but should never reach a browser. Keep it server side.

Project setup

mkdir mcp-billing-server && cd mcp-billing-server
npm init -y
npm install @modelcontextprotocol/sdk zod @supabase/supabase-js stripe
npm install -D typescript @types/node

Set package.json to ESM and add a build plus start script:

{
  "name": "mcp-billing-server",
  "version": "1.0.0",
  "type": "module",
  "bin": { "mcp-billing-server": "dist/index.js" },
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

A minimal tsconfig.json that emits modern ESM:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*"]
}

Folder layout:

mcp-billing-server/
  src/
    clients.ts    // Supabase + Stripe client setup
    index.ts      // server, transport, tool registration
  tsconfig.json
  package.json
  .env

A note on SDK versions. This tutorial targets @modelcontextprotocol/sdk version 1.29.x, the current stable line. The SDK is mid-migration toward split packages (@modelcontextprotocol/server and friends) that wrap input schemas in z.object(...). Those are still alpha at the time of writing. On the stable 1.x SDK, inputSchema is a raw zod shape object, which is what the code below uses. Run npm ls @modelcontextprotocol/sdk to confirm your installed version, and if you are on a 2.x alpha, wrap each inputSchema value in z.object(...) and adjust the import paths.

The clients

Keep external client construction in one file so each tool handler stays focused on logic. Create src/clients.ts:

import { createClient } from "@supabase/supabase-js";
import Stripe from "stripe";

function required(name: string): string {
  const value = process.env[name];
  if (!value) {
    throw new Error(`Missing required environment variable: ${name}`);
  }
  return value;
}

export const supabase = createClient(
  required("SUPABASE_URL"),
  required("SUPABASE_SERVICE_ROLE_KEY"),
  { auth: { persistSession: false } }
);

export const stripe = new Stripe(required("STRIPE_SECRET_KEY"));

Two things worth noting. The required helper fails loudly at startup instead of letting a tool blow up later with a confusing null. And persistSession: false tells the Supabase client not to try to manage auth sessions, which is correct for a stateless server process.

The Stripe constructor pins to the API version baked into the SDK release you installed. That is the safe default. If you need to pin explicitly, pass { apiVersion: "2025-..." } matching a version your account supports, and verify it against your dashboard rather than copying a string from a blog post.

Building the server

Now src/index.ts. Start with imports, the server instance, and the zod import. On the stable SDK the transport and server live at subpaths under @modelcontextprotocol/sdk:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

import { supabase, stripe } from "./clients.js";

const server = new McpServer({
  name: "billing-server",
  version: "1.0.0",
});

The .js extension on the local ./clients.js import is not a typo. Under Node16 module resolution, TypeScript wants the emitted extension in relative import specifiers even though the source file is clients.ts.

Tool 1: look up a customer

registerTool takes the tool name, a config object with a description and an inputSchema, and an async handler. The handler receives the validated arguments and returns a result with a content array. Here is the full first tool:

server.registerTool(
  "lookup_customer",
  {
    title: "Look up customer",
    description:
      "Find a customer by email address. Returns the internal id and the linked Stripe customer id.",
    inputSchema: {
      email: z
        .string()
        .email()
        .describe("The customer's email address, e.g. jane@example.com"),
    },
  },
  async ({ email }) => {
    const { data, error } = await supabase
      .from("customers")
      .select("id, email, stripe_customer_id")
      .eq("email", email)
      .maybeSingle();

    if (error) {
      return {
        content: [{ type: "text", text: `Database error: ${error.message}` }],
        isError: true,
      };
    }

    if (!data) {
      return {
        content: [{ type: "text", text: `No customer found for ${email}.` }],
        isError: true,
      };
    }

    return {
      content: [{ type: "text", text: JSON.stringify(data, null, 2) }],
    };
  }
);

A few deliberate choices. inputSchema is an object of zod validators, one per argument, and .describe(...) on each field becomes documentation the model reads when deciding how to call the tool. The more specific the description, the fewer malformed calls you get. maybeSingle() returns null instead of throwing when no row matches, which lets us return a clean "not found" tool error rather than a 500. We distinguish a real database error from a simple miss, because the model should react differently to each.

Tool 2: list recent Stripe charges

This tool needs the Stripe customer id, which the previous tool surfaces. We accept it directly so the model can chain the two calls:

server.registerTool(
  "list_recent_charges",
  {
    title: "List recent charges",
    description:
      "List a customer's most recent Stripe charges. Pass the Stripe customer id (starts with cus_).",
    inputSchema: {
      stripeCustomerId: z
        .string()
        .startsWith("cus_")
        .describe("Stripe customer id, e.g. cus_ABC123"),
      limit: z
        .number()
        .int()
        .min(1)
        .max(100)
        .default(10)
        .describe("How many charges to return, 1 to 100"),
    },
  },
  async ({ stripeCustomerId, limit }) => {
    try {
      const charges = await stripe.charges.list({
        customer: stripeCustomerId,
        limit,
      });

      const summary = charges.data.map((c) => ({
        id: c.id,
        amount: c.amount,
        currency: c.currency,
        status: c.status,
        created: new Date(c.created * 1000).toISOString(),
        description: c.description,
      }));

      return {
        content: [{ type: "text", text: JSON.stringify(summary, null, 2) }],
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        content: [{ type: "text", text: `Stripe error: ${message}` }],
        isError: true,
      };
    }
  }
);

The limit field shows two useful patterns at once. The zod constraints (int, min, max) mean the model cannot ask for 5000 charges, and .default(10) means it can omit the field entirely. We also reshape the Stripe response before returning it. Stripe charge objects are large, and dumping the raw object wastes the model's context window on fields it will not use. Return only what the tool promises.

Stripe stores amounts in the smallest currency unit and timestamps as Unix seconds, which is why amount stays an integer and created gets multiplied by 1000 before becoming an ISO string. Surface data in the shape a reader of the output would expect.

Tool 3: create a payment link

This is the write tool, so it gets the tightest schema and the clearest description of what it does:

server.registerTool(
  "create_payment_link",
  {
    title: "Create payment link",
    description:
      "Create a Stripe payment link for an existing Price. This creates a real billing object. Returns a URL the customer can pay at.",
    inputSchema: {
      priceId: z
        .string()
        .startsWith("price_")
        .describe("Stripe Price id, e.g. price_ABC123"),
      quantity: z
        .number()
        .int()
        .min(1)
        .max(99)
        .default(1)
        .describe("Quantity of the item, 1 to 99"),
    },
  },
  async ({ priceId, quantity }) => {
    try {
      const link = await stripe.paymentLinks.create({
        line_items: [{ price: priceId, quantity }],
      });

      return {
        content: [
          {
            type: "text",
            text: `Payment link created: ${link.url}`,
          },
        ],
      };
    } catch (err) {
      const message = err instanceof Error ? err.message : String(err);
      return {
        content: [{ type: "text", text: `Stripe error: ${message}` }],
        isError: true,
      };
    }
  }
);

Wire up the transport

Finally, connect the server to a stdio transport. Stdio is the right choice for a local server that an MCP client spawns as a child process, which is exactly how Claude Desktop runs it:

async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("billing-server running on stdio");
}

main().catch((err) => {
  console.error("Fatal error:", err);
  process.exit(1);
});

Note the console.error, not console.log. On a stdio transport, standard output is the protocol channel. Anything you print to stdout will corrupt the JSON-RPC stream and break the connection. All logging goes to stderr.

Run it and connect it

Build, then load your environment and start the server:

npm run build
node --env-file=.env dist/index.js

You should see billing-server running on stdio on stderr and then a wait. That is correct; the server is now listening for a client over stdin.

To use it from Claude Desktop, add an entry to its config file. On macOS that is ~/Library/Application Support/Claude/claude_desktop_config.json, and on Windows it is %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "billing": {
      "command": "node",
      "args": ["--env-file=.env", "/absolute/path/to/dist/index.js"]
    }
  }
}

Use an absolute path to dist/index.js. Restart the client, and your three tools appear. Ask the agent to look up a customer by email, and it will call lookup_customer, then chain into list_recent_charges with the Stripe id it just got back.

Make it production real

A demo that works on the happy path is not the same as a server you would trust against live data. A few things separate the two.

Input validation is your first defense. The zod schemas above are not decoration. Because the SDK validates arguments against the schema before your handler runs, a malformed call is rejected before it can touch Stripe or Supabase. Make schemas as strict as the real constraint: startsWith("cus_") for a Stripe customer id, min and max on any count, .email() on an address. Every constraint you add is a class of bad call the model cannot make.

Return proper MCP tool errors. There are two failure modes and they are not the same. A thrown error from your handler is automatically caught by the SDK and converted into a tool result with isError: true, so the model sees the message and can self correct. Returning { isError: true } yourself does the same thing explicitly, which is the right move for expected conditions like "no customer found." Reserve thrown exceptions for genuinely unexpected failures. Either way, never let a raw stack trace become the model's only signal.

Draw a read versus write boundary. Two of these tools only read. One creates a billing object. That distinction should be visible in the tool description so the model treats it with appropriate caution, and it should shape how you scope credentials. If a server only needs to read, give it a restricted Stripe key and a Supabase role that cannot write. For write tools, consider gating the action behind an idempotency key or a confirmation step so a retried call does not create duplicate payment links.

Handle secrets like secrets. The keys here grant real access. The required helper keeps them out of source, the .env file stays out of version control, and the service role key never crosses to a client. In a deployed setting, pull these from a secrets manager rather than a file. The fastest way to turn a useful MCP server into an incident is to commit the key that powers it.

Wrap up

You now have a typed MCP server that exposes a database and a payments processor to an AI agent, with zod-validated inputs, clean tool errors, and a clear line between reading and writing. The pattern generalizes. Any system you can reach from Node, you can wrap in a tool, describe with a schema, and hand to an agent under your own rules. Start read only, add writes deliberately, and keep the schemas tight.


Josh Elberg is the founder of Palavir, where he builds production AI and data tools across payments, government data, and document automation. He writes about wiring real business systems to AI agents without losing control of either.

About the Author

Founder & Principal Consultant

Josh helps SMBs implement AI and analytics that drive measurable outcomes. With experience building data products and scaling analytics infrastructure, he focuses on practical, cost-effective solutions that deliver ROI within months, not years.

Want this for your own business?

The AI Opportunity Audit is a fixed-fee review of your workflows ($2,500 for one workflow, $5,000 for a multi-workflow operation): an AI readiness scorecard, prioritized automation candidates with build estimates and ROI math, and a 90-day rollout plan.

Scoped on a short fit call, then a fixed-fee proposal — no retainer.