Shopify merchants want AI agents to answer inventory questions like “which products are low on stock” or “show me variants under $20”, but exposing raw Shopify Admin API keys to a shared agent risks data leakage and API abuse. They need a secure, multi‑tenant gateway that enforces per‑store permissions and prevents one client from overwhelming the API.
A complete, working implementation of this recipe — downloadable as a zip or browsable file by file. Generated by our build pipeline; tested with full coverage before publishing.
This tutorial builds a multi-tenant MCP (Model Context Protocol) server that gives Vertex AI agents secure, rate-limited access to real-time Shopify inventory data. You’ll create an Express server that authenticates requests via API key, enforces per-store rate limits, restricts which tools each merchant can see, and exposes three MCP tools — getProduct, listInventory, and searchVariants — that call the Shopify Admin API under the hood.
The middleware stack runs multiple merchants side by side with no data cross-contamination. Vertex AI agents interact with the MCP tools via standard JSON-RPC over HTTP at POST /mcp/:tenantId.
This walkthrough is for TypeScript developers who are comfortable with Express, middleware patterns, and REST APIs. You’ll follow along in a terminal, pasting code blocks into your own project directory.
Prerequisites
Node.js 22+ and pnpm 10 installed on your machine
A Shopify partner account or test store with a private app that has read_products scope (for API credentials)
A Langfuse account (free tier) — optional but used for observability tracing
Basic familiarity with Express middleware and REST API concepts
Step 1: Scaffold the project and install dependencies
Start with a fresh project directory containing the scaffold files (tsconfig.json, eslint.config.mjs, vitest.config.ts, and next.config.ts) and install all the packages this recipe depends on.
Expected output: All packages added to package.json with exact-pinned versions (no ^ or ~ prefixes). Your package.json scripts block contains dev, build, start, lint, typecheck, and test commands.
Step 2: Create the tenant configuration registry
Every merchant is a “tenant” with its own Shopify store URL, access token, rate limits, and allowed tools. You’ll define a Zod schema to validate tenant configs and a TenantRegistry class to manage them.
Expected output: A module that exports TenantRegistry, TenantConfig, and tenantConfigSchema. The schema enforces that shopifyStoreUrl is a valid URL, accessToken is non-empty, rate limits are positive integers, and burstSize and allowedTools default to sensible values when omitted.
Step 3: Build the Shopify API client
The ShopifyClient wraps the @shopify/shopify-api library to make REST calls to a merchant’s store. It creates a REST client per-call using a Session constructed from the store URL and access token, and maps raw API responses to typed result interfaces.
Expected output: A module exporting ShopifyClient, ShopifyApiError, and three result interfaces. Each method creates a fresh REST client from the store URL and access token, maps the Shopify response to a typed result, and wraps errors in ShopifyApiError with the HTTP status code.
Step 4: Define the MCP inventory tools
Each MCP tool is a ToolDefinition with a name, description, inputSchema (using Zod), and an async handler function. The handler receives arguments and a tenant context, calls the ShopifyClient, and returns MCP-formatted content.
Create src/mcp/tools/inventory.ts:
ts
import { z } from "zod";import type { TenantConfig } from "../../config/tenant-registry.js";import { ShopifyClient, ShopifyApiError } from "../../lib/shopify-client.js";export interface McpContentResult { content: Array<{ type: "text"; text: string }>; isError?: boolean;}export interface ToolDefinition { name: string; description: string; inputSchema: z.ZodType; handler: (
Expected output: Three tool definitions exported as getProductTool, listInventoryTool, searchVariantsTool, and combined in the inventoryTools array. Each tool has a Zod-validated input schema and a handler that catches ShopifyApiError and returns MCP content with isError on failure. The createInventoryToolHandlers factory lets you inject a custom Shopify client instance.
Step 5: Compose the middleware pipeline
The middleware pipeline layers authentication, rate limiting, and multi-tenant tool visibility. Each concern is a reusable REAA package wired together in a single module.
Create src/middleware/tenant-pipeline.ts:
ts
import { createMultiTenantMiddleware,} from "@reaatech/multi-tenant-mcp-middleware";import { authMiddleware, hasScope as _hasScope, AuthenticationError,} from "@reaatech/mcp-gateway-auth";import { createRateLimiter, createRateLimitMiddleware,} from "@reaatech/mcp-gateway-rate-limit";import { VisibilityEngineImpl, type BaseVisibilityPolicy } from "@reaatech/multi-tenant-mcp-tool-visibility";import { TenantContextStore } from "@reaatech/multi-tenant-mcp-tenant-resolver";import type { RequestHandler } from "express";import type { TenantRegistry } from "../config/tenant-registry.js";export function buildAuthMiddleware( _registry: TenantRegistry,): RequestHandler { return authMiddleware({ onFailure: (_error: AuthenticationError, _req: unknown) => { console.warn(`Auth failed: ${_error.code}`); }, });}export function buildRateLimitMiddleware( _registry: TenantRegistry,): RequestHandler { const limiter = createRateLimiter({ storeType: "memory", defaultConfig: { requestsPerMinute: 100, requestsPerDay: 10000, burstSize: 50, }, }); return createRateLimitMiddleware(limiter);}export function buildVisibilityEngine( registry: TenantRegistry,): VisibilityEngineImpl<BaseVisibilityPolicy> { const policies: Record<string, BaseVisibilityPolicy> = {}; for (const tenantId of registry.listTenants()) { const config = registry.getTenant(tenantId) as { allowedTools: string[] }; policies[tenantId] = { type: "allow", items: config.allowedTools, }; } return new VisibilityEngineImpl<BaseVisibilityPolicy>(policies);}export function buildMultiTenantMiddleware( registry: TenantRegistry, _visibilityEngine: VisibilityEngineImpl<BaseVisibilityPolicy>,): { mw: ReturnType<typeof createMultiTenantMiddleware> } { const store = new TenantContextStore(); const toolVisibility: Record<string, BaseVisibilityPolicy> = {}; for (const tenantId of registry.listTenants()) { const config = registry.getTenant(tenantId) as { allowedTools: string[] }; toolVisibility[tenantId] = { type: "allow", items: config.allowedTools, }; } const mw = createMultiTenantMiddleware({ tenantContextStore: store, toolVisibility, logger: { info: console.log.bind(console, "[tenant-pipeline]"), warn: console.warn.bind(console, "[tenant-pipeline]"), error: console.error.bind(console, "[tenant-pipeline]"), debug: console.debug.bind(console, "[tenant-pipeline]"), }, }); return { mw };}export interface Pipeline { authMw: RequestHandler; rateLimitMw: RequestHandler; mw: ReturnType<typeof createMultiTenantMiddleware>;}export function buildPipeline(registry: TenantRegistry): Pipeline { const authMw = buildAuthMiddleware(registry); const rateLimitMw = buildRateLimitMiddleware(registry); const visibilityEngine = buildVisibilityEngine(registry); const { mw } = buildMultiTenantMiddleware(registry, visibilityEngine); return { authMw, rateLimitMw, mw };}
Expected output: A module that exports each builder function and a top-level buildPipeline() that assembles the full pipeline. The authMiddleware handles API key and JWT validation. The rate limiter uses an in-memory token bucket. The visibility engine maps each tenant’s allowedTools array to an allow-list policy.
Step 6: Wire the MCP server with middleware
The MCP server creates an SDK Server instance, wires tools/list and tools/call handlers through the multi-tenant middleware, and exports a handler set for use in Express routes.
Expected output:createMcpServer() builds the pipeline, creates an SDK Server with the name "shopify-inventory-mcp", and registers two handlers through mw.handle() — tools/list returns three tools with their input schemas, and tools/call dispatches to the correct handler by name. The multi-tenant middleware enforces visibility and rate limits transparently.
Step 7: Create Express route handlers
The handlers.ts module implements the Express route logic for POST /mcp/:tenantId (JSON-RPC dispatch) and GET /health. It reads the tenant from the URL parameter, validates authentication, and delegates to the MCP handler set.
Expected output: Three exported functions. mcpPostHandler validates auth via getAuth(), looks up the tenant, parses the JSON-RPC body, and dispatches to tools/list or tools/call. healthGetHandler returns server status and tenant list. handleError logs the error and returns a JSON-RPC internal error response.
Step 8: Wire the Express app entry point
The central src/index.ts creates the Express app, seeds the tenant registry with example merchants, builds the middleware pipeline, and mounts the routes.
Expected output: An Express app exported via createApp() that creates an Express instance with JSON body parsing, builds the middleware pipeline, and mounts POST /mcp/:tenantId (protected by auth and rate-limit middleware) and GET /health. The startServer() function starts listening on port 3001 by default.
Step 9: Add observability with Langfuse
The RecipeLogger wraps the Langfuse SDK to create traces for each tool call and log errors. It degrades gracefully when Langfuse credentials aren’t configured.
Expected output: A RecipeLogger singleton that lazy-initializes the Langfuse client when trace() is first called. If LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY aren’t set, it falls back to JSON console logging. The shutdown() method flushes pending events.
Step 10: Configure environment variables
Populate .env.example in the project root with every environment variable the server reads:
env
# Env vars used by vertex-ai-inventory-mcp-server-for-shopify-merchants.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=development# Shopify API credentialsSHOPIFY_API_KEY=<your-shopify-api-key>SHOPIFY_API_SECRET_KEY=<your-shopify-api-secret># Per-store access tokens (set via tenant registry code)SHOPIFY_ACCESS_TOKEN_STORE_1=<your-shopify-token-for-store-1>SHOPIFY_ACCESS_TOKEN_STORE_2=<your-shopify-token-for-store-2># Langfuse observabilityLANGFUSE_PUBLIC_KEY=<your-langfuse-public-key>LANGFUSE_SECRET_KEY=<your-langfuse-secret-key>LANGFUSE_HOST=https://cloud.langfuse.com# ServerPORT=3001
Expected output: A .env.example file in the project root. Copy it to .env and fill in real values from your Shopify private app and Langfuse dashboard.
Step 11: Run the test suite
The recipe includes tests for every layer — tenant registry validation, Shopify client API calls (with mocked HTTP), MCP tool handlers, middleware composition, Express integration, and the Next.js health route. Run the type checker and test suite with:
terminal
pnpm typecheckpnpm vitest run --coverage
Expected output: TypeScript type checking passes with zero errors, then Vitest runs the full test suite across 26 suites (87 tests). All external services are mocked — @shopify/shopify-api calls use vi.mock, and the REAA packages are mocked in pipeline tests. No live HTTP calls are made.
The project includes a Next.js health endpoint at app/api/health/route.ts. Start the Next.js development server and verify it responds:
terminal
pnpm dev
In another terminal, send a health check:
terminal
curl http://localhost:3000/api/health
Expected output:
json
{"status":"ok","service":"shopify-inventory-mcp"}
The Express MCP server (createApp / startServer from src/index.ts) is tested directly via the test suite (Step 11). To run it standalone alongside your Next.js app, create a small script that calls createApp() and startServer() — it listens on port 3001 and exposes POST /mcp/:tenantId and GET /health.
Next steps
Swap in Redis-backed rate limiting — pass a Redis client to createRateLimiter({ storeType: "redis", redisClient }) so rate limit state survives process restarts and scales across multiple instances
Add a fourth MCP tool — implement getOrders(startDate, endDate) following the same pattern: define a ToolDefinition with a Zod schema, add a method to ShopifyClient, and register it in inventoryTools
Integrate with Vertex AI Agent Builder — create a Vertex AI agent that discovers the MCP tools automatically via the tools/list endpoint and uses tools/call to answer merchant inventory questions in natural language
export interface VariantResult {
id: string;
title: string;
sku: string;
price: string;
inventoryQuantity: number;
}
export class ShopifyApiError extends Error {
constructor(
message: string,
public statusCode: number,
public apiMessage: string,
) {
super(message);
this.name = "ShopifyApiError";
}
}
function safeString(val: unknown, fallback: string): string {
if (typeof val === "string" || typeof val === "number") return String(val);
return fallback;
}
function safeNumber(val: unknown, fallback: number): number {