DEV Community

sms-florin
sms-florin

Posted on

WebMCP is not MCP: I wired real Stripe checkout into a browser AI tool, here's the code

WebMCP is not MCP: I wired real Stripe checkout into a browser AI tool, here's the code

WebMCP shipped as a spec four days ago (August 25, 2026, W3C Web Machine Learning Community Group), and the same day OpenAI added support for it in ChatGPT Desktop's browser. I spent yesterday wiring it into a live product — real inventory, real Stripe checkout, real money — instead of a toy demo, and I want to write down what that actually looks like in code, because most of what's out there right now is spec text and announcement posts.

WebMCP is not MCP. The name is doing you a disservice.

MCP (Model Context Protocol, Anthropic, late 2024) is a client-server protocol: your AI client — Claude Desktop, an IDE, whatever — connects out to a server process you run, over stdio or HTTP, and that server exposes tools. The server has no idea it's running inside a browser or a chat client. It just answers JSON-RPC calls.

WebMCP flips that. It's a browser API: document.modelContext.registerTool(). A web page, running in a user's actual browser tab, registers tools directly with whatever AI agent is embedded in or driving that browser. There's no separate server process, no protocol handshake, no client you have to configure to "connect" to your site. If the page is open and the browser is WebMCP-aware, the tools exist. If it's a normal browser, document.modelContext is undefined and nothing happens — it's naturally feature-detected, not an opt-in flag you flip.

Practically: MCP is "give an AI client a new server to talk to." WebMCP is "give this specific page's visitor's agent something to click for them, without it having to guess at your DOM." Same three letters in the acronym, unrelated plumbing.

What I built: two tools on a real product page

sms-florin sells phone numbers and eSIMs — the eSIM catalog page (/esim) is real: 10 SKUs across 5 countries, live Stripe Checkout, no test mode. I added a client component that registers two tools the moment the page mounts, and does nothing if document.modelContext isn't there:

// src/app/esim/webmcp-tools.tsx
"use client";

export function WebmcpTools() {
  useEffect(() => {
    const modelContext = (document as unknown as {
      modelContext?: { registerTool: (...) => Promise<void> };
    }).modelContext;
    if (!modelContext) return;

    const controller = new AbortController();

    modelContext.registerTool(
      {
        name: "list_esim_plans",
        description:
          "List available eSIM data plans (country, data amount, duration, price in USD).",
        inputSchema: { type: "object", properties: {} },
        execute: async () => {
          const res = await fetch("/api/v1/esim-catalog");
          return await res.json();
        },
      },
      { signal: controller.signal },
    );

    modelContext.registerTool(
      {
        name: "buy_esim_plan",
        description:
          "Start a real purchase for an eSIM plan by its slug (from list_esim_plans). " +
          "Navigates to a real Stripe checkout page — no charge happens until a human " +
          "enters card details there.",
        inputSchema: {
          type: "object",
          properties: {
            slug: { type: "string", description: "Plan slug, e.g. 'uk-1gb-7d'" },
          },
          required: ["slug"],
        },
        execute: async ({ slug }) => {
          const res = await fetch("/api/v1/esim-checkout", {
            method: "POST",
            headers: { "content-type": "application/json" },
            body: JSON.stringify({ slug }),
          });
          const data = await res.json();
          if (data.checkoutUrl) {
            window.location.href = data.checkoutUrl;
            return `Redirecting to checkout: ${data.checkoutUrl}`;
          }
          return data.error ?? "Could not start checkout.";
        },
      },
      { signal: controller.signal },
    );

    return () => controller.abort();
  }, []);

  return null;
}
Enter fullscreen mode Exit fullscreen mode

Two things worth calling out. First, registerTool's execute runs in the page's own JS context, with the page's own cookies, origin, and CSP — it's not a sandboxed RPC to some remote definition. Second, the AbortController cleanup matters more than it looks: if this were a SPA route and the component unmounted, stale tools with closures over a dead page would otherwise sit registered.

Both tools call the same public JSON endpoints the page itself would use if this were rendered as an API instead of HTMLGET /api/v1/esim-catalog and POST /api/v1/esim-checkout. I didn't invent a new surface for the agent; I exposed the existing data and existing checkout flow as JSON, because a WebMCP tool's execute() needs to return a value or navigate — it can't do a server-side redirect the way a Next.js form action can.

Why the checkout logic lives in one shared function

The interesting design decision wasn't the tool registration, it was making sure the AI-agent path and the human path are provably the same Stripe session logic, not two implementations that can drift.

// src/lib/esim-checkout.ts
export async function createEsimCheckoutSession(
  slug: string,
): Promise<{ url: string } | { error: string }> {
  const plan = getEsimPlanBySlug(slug);
  if (!plan) return { error: "Invalid plan." };

  const planLabel = `${plan.label} ${plan.dataAmount} / ${plan.durationDays} days`;
  const stripe = getStripe();
  const session = await stripe.checkout.sessions.create({
    mode: "payment",
    payment_method_types: ["card"],
    billing_address_collection: "required",
    line_items: [{
      price_data: {
        currency: "usd",
        product_data: { name: `eSIM — ${planLabel}` },
        unit_amount: plan.priceCents,
      },
      quantity: 1,
    }],
    metadata: { type: "esim", packageCode: plan.packageCode, planLabel },
    success_url: `${APP_URL}/esim/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${APP_URL}/esim`,
  });

  if (!session.url) return { error: "Could not create the payment session." };
  return { url: session.url };
}
Enter fullscreen mode Exit fullscreen mode

This one function is called from two places: the existing server action behind the human "Buy" button on /esim (which redirects the browser directly, Next.js-style), and the new /api/v1/esim-checkout route (which the buy_esim_plan tool calls, and which returns the URL as JSON instead of redirecting, because a fetch() inside execute() needs a value back, not a 303).

// src/app/api/v1/esim-checkout/route.ts
export async function POST(request: NextRequest) {
  const ip = await getClientIp();
  if (!checkRateLimit(`esim-checkout:${ip}`, 10, 60 * 60 * 1000)) {
    return NextResponse.json({ error: "rate limited" }, { status: 429 });
  }

  const body = await request.json().catch(() => null);
  const parsed = buyEsimSchema.safeParse(body);
  if (!parsed.success) {
    return NextResponse.json({ error: "Invalid plan." }, { status: 400 });
  }

  const result = await createEsimCheckoutSession(parsed.data.slug);
  if ("error" in result) {
    return NextResponse.json({ error: result.error }, { status: 400 });
  }

  return NextResponse.json({ checkoutUrl: result.url });
}
Enter fullscreen mode Exit fullscreen mode

Same Zod validation (buyEsimSchema), same rate limiting (checkRateLimit, keyed on IP, 10/hour on checkout, 60/minute on catalog reads), same Stripe session creation. The only fork in the road is the last line: redirect vs. JSON. Everything a human path already had to get right — bad slugs, abuse, Stripe error handling — the agent path gets for free, because it's not a separate implementation, it's the same function with a thinner wrapper.

The catalog endpoint is the boring half, deliberately:

// src/app/api/v1/esim-catalog/route.ts
export async function GET(request: NextRequest) {
  const ip = await getClientIp();
  if (!checkRateLimit(`api:esim-catalog:${ip}`, 60, 60 * 1000)) {
    return NextResponse.json({ error: "rate limited" }, { status: 429 });
  }

  return NextResponse.json({
    plans: ESIM_CATALOG.map((plan) => ({
      slug: plan.slug,
      country: plan.country,
      dataAmount: plan.dataAmount,
      durationDays: plan.durationDays,
      priceCents: plan.priceCents,
    })),
  });
}
Enter fullscreen mode Exit fullscreen mode

It's the exact same ESIM_CATALOG array that renders the pricing cards on the page, just mapped to JSON instead of JSX. No agent-only pricing, no shadow catalog that can go stale relative to what a human sees.

Why "no toy demo" is the whole point

It would be easy to build a WebMCP tool that calls a mock endpoint and prints a fake confirmation — a lot of the example repos floating around right now do exactly that, understandably, since the spec is four days old. I wanted to know what actually breaks when the tool is wired to a live Stripe account instead.

The honest answer: nothing broke, because the design constraint I put on myself was that no money moves inside execute(). buy_esim_plan does not charge a card. It creates a Stripe Checkout Session and hands back a URL. The agent (or the human driving it) still lands on checkout.stripe.com, still has to type in a real card number, still sees Stripe's own fraud checks and 3-D Secure flow. The WebMCP tool's entire job is to save the human the five clicks of picking a plan from a page — it deliberately stops exactly at the boundary where PCI scope and "did a human actually authorize this charge" would become real questions. That boundary isn't a workaround I bolted on for the demo; it's just where a Checkout Session naturally ends and Stripe's hosted page begins, so it cost nothing to keep the agent path honest.

That's also why I didn't try to make the tool "smarter" — no auto-selecting a plan based on inferred intent, no skipping the redirect. A buy_esim_plan tool that silently picked the most expensive plan or found a way to skip the human at the card page would be a much more interesting attack surface than a useful feature.

Code and live product

Demo repo (extracted, minimal, MIT): https://github.com/flovoice53-tech/sms-florin-webmcp-demo
Live page these tools are actually registered on: https://flo-voice1.com/esim (open it in a WebMCP-aware browser and document.modelContext will be populated; anywhere else, the page just works like a normal Next.js page, because that's all it is otherwise).

If you're building on WebMCP and hit anything about the execute()-can't-redirect constraint, or how you're structuring shared logic between the human path and the tool path, I'd genuinely like to compare notes — leave a comment.

Top comments (0)