SMBs want AI agents that can access real-time business data from HubSpot or QuickBooks, but manually building secure, authenticated, and rate-limited tool endpoints for each system is time-consuming and fragile.
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 walks you through building an MCP (Model Context Protocol) server that exposes HubSpot CRM operations as type-safe tools any MCP-compatible client — including Anthropic’s Claude — can discover and invoke. You’ll wire five @reaatech packages around a Hono HTTP server, adding API-key authentication, per-tenant tool allowlists, token-bucket rate limiting, and tamper-evident audit logging along the way.
Prerequisites
Node.js >=22 installed on your machine
pnpm 10.x (install with npm install -g pnpm if you don’t have it)
A HubSpot private app access token with CRM scopes (deals, contacts, companies) — create one in your HubSpot account under Settings -> Integrations -> Private Apps
Familiarity with TypeScript and basic MCP concepts
Your terminal pointed at an empty project directory
Step 1: Initialize the project and install dependencies
Start from an empty directory. Create the project with pnpm, then replace the auto-generated package.json with exact-pinned versions so builds are reproducible.
Expected output:pnpm typecheck passes with no errors once you have at least one .ts file.
Step 3: Create next.config.ts
The project uses Next.js as its build toolchain. Create a minimal next.config.ts at the project root:
ts
import type { NextConfig } from "next";const nextConfig: NextConfig = { /* config options here */};export default nextConfig;
Expected output:next.config.ts at the project root. The dev, build, and start scripts in package.json all depend on this file being present.
Step 4: Set up environment variables
Create .env.example with all the variables your server reads:
env
# Env vars used by anthropic-mcp-server-for-smb-erp-tool-access.# The builder adds entries here as it wires up each integration.# Keep placeholders only — never commit real values.NODE_ENV=developmentHUBSPOT_ACCESS_TOKEN=<your-hubspot-private-app-token>HUBSPOT_DEVELOPER_API_KEY=<your-hubspot-developer-api-key>PORT=8080CORS_ORIGIN=*API_KEY=<your-mcp-api-key>AUTH_MODE=api-keyLOG_LEVEL=infoTENANT_CONFIG_PATH=./tenants.json
Copy and edit for local development:
terminal
cp .env.example .env
Create tenants.json.example — the JSON file maps tenant IDs to their HubSpot credentials, tool allowlists, and rate limits:
Expected output:src/types.ts with TenantConfig, ToolName, HubspotCredentials, and ToolCallResult.
Step 6: Create the configuration module
Create src/config.ts. It wraps @reaatech/mcp-server-core’s getEnvConfig() to provide typed, lazy-loaded config values. The createConfigProxy function returns getters that call getEnvConfig() on each access — this means your code reads the actual environment at the moment of access, not at import time, which makes testing easier because you can mutate process.env between test cases.
ts
import { getEnvConfig, resetEnvConfigCache, isProduction } from '@reaatech/mcp-server-core';function createConfigProxy() { return { get port(): number { return getEnvConfig().PORT; }, get nodeEnv(): string { return getEnvConfig().NODE_ENV; }, get apiKey(): string | undefined { return getEnvConfig().API_KEY; }, get authMode(): string { return getEnvConfig().AUTH_MODE; }, get logLevel(): string { return getEnvConfig().LOG_LEVEL; }, get corsOrigin(): string { return getEnvConfig().CORS_ORIGIN; }, };}export const config = createConfigProxy();export function loadConfig(): void { getEnvConfig();}export function isHttps(): boolean { return isProduction();}export function resetConfig(): void { resetEnvConfigCache();}
Expected output:src/config.ts exporting config, loadConfig, isHttps, and resetConfig.
Step 7: Build the tenant store
Create src/glue/tenant-store.ts. This provides two implementations of TenantStore — JsonFileTenantStore reads tenant config from a JSON file on disk, and InMemoryTenantStore accepts an array in memory for testing. The factory function createTenantStore picks the right one based on a string parameter.
ts
import { readFileSync } from 'node:fs';import type { TenantConfig } from '../types.js';export interface TenantStore { getTenant(tenantId: string): TenantConfig | undefined; listTenants(): TenantConfig[];}export class InMemoryTenantStore implements TenantStore { private tenants: Map<string, TenantConfig>; constructor(initial?: TenantConfig[]) { this.tenants = new Map(); if (initial) { for (const t of initial) { this.tenants.set(t.tenantId, t); } } } getTenant(tenantId: string): TenantConfig | undefined { return this.tenants.get(tenantId); } listTenants(): TenantConfig[] { return Array.from(this.tenants.values()); }}export class JsonFileTenantStore implements TenantStore { private tenants: Map<string, TenantConfig>; constructor(filePath?: string) { const path = filePath ?? process.env.TENANT_CONFIG_PATH ?? './tenants.json'; this.tenants = new Map(); try { const content = readFileSync(path, 'utf-8'); const parsed = JSON.parse(content) as Record<string, TenantConfig>; for (const [id, cfg] of Object.entries(parsed)) { this.tenants.set(id, { ...cfg, tenantId: id }); } } catch { this.tenants = new Map(); } } getTenant(tenantId: string): TenantConfig | undefined { return this.tenants.get(tenantId); } listTenants(): TenantConfig[] { return Array.from(this.tenants.values()); }}export function createTenantStore(type: 'json-file' | 'memory', initial?: TenantConfig[]): TenantStore { if (type === 'json-file') { return new JsonFileTenantStore(); } return new InMemoryTenantStore(initial);}
Expected output:src/glue/tenant-store.ts with both store types and the factory function.
Step 8: Create the auth resolver
Create src/glue/auth-resolver.ts. This module maps incoming auth contexts to HubSpot credentials by looking up the tenant in the store, plus safe logging helpers from @reaatech/mcp-gateway-auth.
ts
import type { AuthContext } from '@reaatech/mcp-gateway-auth';import { hasScope, generateTokenFingerprint, getRedactedAuthContext } from '@reaatech/mcp-gateway-auth';import type { TenantStore } from './tenant-store.js';import type { HubspotCredentials } from '../types.js';export function resolveTenantFromAuth(auth: AuthContext): string { if (!auth.tenantId) { throw new Error('Missing tenantId in auth context'); } return auth.tenantId;}export function resolveHubspotCredentials(tenantId: string, store: TenantStore): HubspotCredentials { const tenant = store.getTenant(tenantId); if (!tenant) { throw new Error(`Tenant not found: ${tenantId}`); } if (!tenant.accessToken) { throw new Error(`No access token configured for tenant: ${tenantId}`); } return { accessToken: tenant.accessToken, developerApiKey: tenant.developerApiKey, };}export function checkScope(auth: AuthContext, requiredScope: string): boolean { return hasScope(auth, requiredScope);}export function redactAuth(auth: AuthContext): Record<string, unknown> { return getRedactedAuthContext(auth);}export function fingerprintToken(rawToken: string): string { return generateTokenFingerprint(rawToken);}
Expected output:src/glue/auth-resolver.ts with resolveTenantFromAuth, resolveHubspotCredentials, checkScope, redactAuth, and fingerprintToken.
Step 9: Build the HubSpot client factory
Create src/lib/hubspot-client.ts. The createHubspotClient function instantiates the HubSpot SDK Client with credentials and 3 retries. HubspotClientManager caches clients per tenant so the same connection is reused across tool invocations.
ts
import { Client } from '@hubspot/api-client';import type { HubspotCredentials } from '../types.js';export function createHubspotClient(credentials: HubspotCredentials): Client { return new Client({ accessToken: credentials.accessToken, developerApiKey: credentials.developerApiKey, numberOfApiCallRetries: 3, });}export class HubspotClientManager { private clients: Map<string, Client> = new Map(); getClient(tenantId: string, credentials: HubspotCredentials): Client { let client = this.clients.get(tenantId); if (!client) { client = createHubspotClient(credentials); this.clients.set(tenantId, client); } return client; } refreshClient(tenantId: string, credentials: HubspotCredentials): Client { const client = createHubspotClient(credentials); this.clients.set(tenantId, client); return client; }}export const DEFAULT_LIMITER_OPTIONS = { minTime: 1000 / 9, maxConcurrent: 6, id: 'hubspot-client-limiter',} as const;
Expected output:src/lib/hubspot-client.ts with createHubspotClient, HubspotClientManager, and DEFAULT_LIMITER_OPTIONS.
Step 10: Create the Hono middleware stack
The gateway security layer has three middlewares wrapping every request to /mcp/*, plus an audit module. Each middleware is a Hono createMiddleware factory. Create each file in src/middleware/.
import { ConsoleAuditLogger, CompositeAuditLogger, createAuditEvent, MemoryAuditStorage, createAuditQueryService } from '@reaatech/mcp-gateway-audit';import type { AuditEvent, AuditEventType } from '@reaatech/mcp-gateway-audit';export const auditLogger = new CompositeAuditLogger();auditLogger.addLogger(new ConsoleAuditLogger());export const auditStorage = new MemoryAuditStorage();export function logAuditEvent(type: AuditEventType, data: Partial<AuditEvent>): void { const event = createAuditEvent(type, data.requestId ?? '', data); auditLogger.log(event); auditStorage.store(event);}export function createAuditQuery(): ReturnType<typeof createAuditQueryService> { return createAuditQueryService(auditStorage);}
Expected output: Four middleware files in src/middleware/ — auth.ts, allowlist.ts, rate-limit.ts, and audit.ts.
Step 11: Define the five MCP tool handlers
Create src/tools/hubspot-tools.ts. Each MCP tool maps to a specific HubSpot SDK operation: validate the input with a Zod schema, call the HubSpot API, serialize the result as a text content block, and catch any errors into an { isError: true } response. The Zod schemas use zod@4.x syntax — note that .email() is a standalone method, not chained from .string().
ts
import { z } from 'zod';import { Client } from '@hubspot/api-client';import { FilterOperatorEnum } from '@hubspot/api-client/lib/codegen/crm/contacts/models/Filter.js';import type { ToolName, ToolCallResult } from '../types.js';export const HubspotReadDealsSchema = z.object({ limit: z.number().optional().default(50), after: z.string().optional(), properties: z.array(z.string()).optional(),});export const HubspotCreateContactSchema = z.object
Expected output:src/tools/hubspot-tools.ts with 5 Zod schemas, 5 handler functions, and the ALL_TOOLS/TOOL_DEFINITIONS registries.
Step 12: Wire the Hono server
Create src/server.ts — the main entry point that connects everything. The middleware stack mounts in order (rate-limit, then auth, then allowlist), and the /mcp handler instantiates an McpServer with all five tools registered, connects it to a WebStandardStreamableHTTPServerTransport, and forwards each request through. The GET /health endpoint provides a liveness check.
ts
import { Hono } from 'hono';import { serve } from '@hono/node-server';import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';import { SERVER_INFO, APP_VERSION } from '@reaatech/mcp-server-core';import { config, loadConfig } from './config.js';import { createTenantStore } from './glue/tenant-store.js';import { resolveHubspotCredentials } from './glue/auth-resolver.js';import type { TenantStore } from './glue/tenant-store.js';import type { HubspotCredentials } from './types.js';import
Expected output:src/server.ts exporting start() and app. The server listens on the configured port and responds with { status: "healthy", version, uptime } at /health.
Step 13: Create the barrel export
Create src/index.ts — the public API surface:
ts
export { start, app } from './server.js';export { ALL_TOOLS, TOOL_DEFINITIONS } from './tools/hubspot-tools.js';
Add a Hono type augmentation module at src/types/hono-augment.d.ts so the authContext and mcpRequestBody context variables are type-safe:
Expected output:src/index.ts and src/types/hono-augment.d.ts.
Step 14: Write the tests
Create the test suite under tests/. The project has 11 test files covering the barrel exports, config, tenant store, auth resolver, each middleware, HubSpot client factory, tools, and the server integration. Here are three of the key files — you can find the full complement in the downloadable artifact.
Barrel exports — tests/index.test.ts
ts
import { describe, it, expect } from 'vitest';describe('barrel exports', () => { it('exports start and app from src/index', async () => { const mod = await import('../src/index.js'); expect(mod).toHaveProperty('start'); expect(mod).toHaveProperty('app'); expect(mod).toHaveProperty('ALL_TOOLS'); expect(mod).toHaveProperty('TOOL_DEFINITIONS'); });});
import { describe, it, expect, vi, beforeEach } from 'vitest';import { Client } from '@hubspot/api-client';vi.mock('@hubspot/api-client', () => ({ Client: vi.fn().mockImplementation(function ( this: { accessToken: string; developerApiKey?: string }, opts: { accessToken: string; developerApiKey?: string }, ) { this.accessToken = opts.accessToken; this.developerApiKey = opts.developerApiKey; }),}));import { createHubspotClient, HubspotClientManager,} from '../../src/lib/hubspot-client.js';import type { HubspotCredentials } from '../../src/types.js';const testCredentials: HubspotCredentials = { accessToken: 'test-token', developerApiKey: 'test-dev-key',};describe('createHubspotClient', () => { it('returns a Client instance', () => { const client = createHubspotClient(testCredentials); expect(client).toBeInstanceOf(Client); }); it('passes credentials to the Client constructor', () => { const client = createHubspotClient(testCredentials); expect(client).toHaveProperty('accessToken', 'test-token'); expect(client).toHaveProperty('developerApiKey', 'test-dev-key'); });});describe('HubspotClientManager', () => { let manager: HubspotClientManager; beforeEach(() => { vi.clearAllMocks(); manager = new HubspotClientManager(); }); it('getClient lazy-creates a client when none exists for tenant', () => { const client = manager.getClient('tenant1', testCredentials); expect(client).toBeInstanceOf(Client); }); it('getClient returns cached client on subsequent calls', () => { const first = manager.getClient('tenant1', testCredentials); const second = manager.getClient('tenant1', testCredentials); expect(second).toBe(first); }); it('refreshClient creates and caches a new client for a tenant', () => { const client = manager.refreshClient('tenant1', testCredentials); expect(client).toBeInstanceOf(Client); expect(manager.getClient('tenant1', testCredentials)).toBe(client); }); it('refreshClient replaces the cached client', () => { const first = manager.refreshClient('tenant1', testCredentials); const otherCreds: HubspotCredentials = { accessToken: 'other-token' }; const second = manager.refreshClient('tenant1', otherCreds); expect(second).not.toBe(first); expect(second).toHaveProperty('accessToken', 'other-token'); });});
Server integration — tests/server.test.ts
This test mocks all REAA packages, the HubSpot SDK, and the MCP transport so no real network calls happen. It uses vi.hoisted() and vi.mock() at the module level to stub every dependency, then verifies the health endpoint, auth gate, and full request pipeline.
Expected output:pnpm test runs all 11 test files with at least 90% line/branch/function/statement coverage and 0 failures.
Step 15: Run the full verification suite
Run these three commands in order. Each must exit with code 0 before proceeding to the next.
terminal
pnpm typecheckpnpm lintpnpm test
Expected output:
pnpm typecheck — zero TypeScript errors
pnpm lint — zero ESLint errors
pnpm test — coverage report shows lines, branches, functions, and statements all >= 90.0%
Next steps
Add more HubSpot tools — extend TOOL_DEFINITIONS with hubspot_update_deal, hubspot_delete_contact, or hubspot_list_companies by adding new Zod schemas and handler functions
Swap in Redis-backed rate limiting — replace the in-memory createRateLimiter with the Redis backend when you deploy across multiple processes
Wire up a real OAuth flow — integrate with HubSpot’s OAuth 2.0 so each tenant can authenticate without hardcoding access tokens in tenants.json