logicspike/docs

MCP (Model Context Protocol)

`apps/mcp-gateway` Implementation Plan

Last Updated: 2026-05-14 Status: Ready to start — Phase 1 Owner: Platform Team Prerequisite reading: architecture.md

This document is the concrete, file-by-file build plan for the new apps/mcp-gateway Cloudflare Worker. It assumes the architecture in architecture.md has been read and approved.

Follow this document sequentially. Every step has exact file contents, commands, and verification steps. Do not skip ahead — order matters because later steps depend on earlier scaffolding.


0. Pre-Flight Checklist

Before writing any code, confirm the following exist and are working:

  • apps/gateway is running locally with wrangler dev on port 8788
  • apps/blog-service is running locally with wrangler dev on port 8791
  • A dev API key exists with blog:* scopes (generate via seller dashboard)
  • Cloudflare account access — you can run wrangler login and deploy
  • You have the RATE_LIMIT_KV namespace ID from existing gateway wrangler.toml: ec19e516e3d5438bbed769298d894e7b

The new mcp-gateway reuses the existing KV namespace (rl: prefix for rate limit, ak: prefix for API key cache). Do not create a new KV namespace.


1. Build Order (Linear)

This is the exact order. Do not parallelize unless noted.

Step 1  →  packages/mcp-core            (shared types + auth — foundation for everything)
Step 2  →  apps/mcp-gateway scaffold     (wrangler.toml, package.json, tsconfig.json)
Step 3  →  apps/mcp-gateway/src/types.ts (Bindings interface)
Step 4  →  apps/mcp-gateway/src/auth.ts  (API key → McpContext)
Step 5  →  apps/mcp-gateway/src/router.ts (namespace → service binding map)
Step 6  →  apps/mcp-gateway/src/server.ts (MCP server assembly)
Step 7  →  apps/mcp-gateway/src/transports/stdio.ts
Step 8  →  apps/mcp-gateway/src/index.ts (Worker entry — HTTP/SSE for later, just health for now)
Step 9  →  apps/blog-service/src/mcp/   (descriptors + internal route handlers)
Step 10 →  blog-service exposes /mcp/* routes
Step 11 →  .mcp.json updated
Step 12 →  End-to-end smoke test

After Step 12, Phase 1 is done. Phase 2 (other products) and Phase 3 (HTTP/SSE) repeat the same pattern.


2. Step 1 — packages/mcp-core

This package contains the shared types and auth logic. Both apps/gateway (eventually) and apps/mcp-gateway import from it.

2.1 Create the package

mkdir -p packages/mcp-core/src
cd packages/mcp-core

2.2 packages/mcp-core/package.json

{
  "name": "@repo/mcp-core",
  "version": "0.0.1",
  "private": true,
  "main": "./src/index.ts",
  "types": "./src/index.ts",
  "scripts": {
    "check-types": "tsc --noEmit"
  },
  "dependencies": {
    "@repo/core-database": "workspace:*",
    "@repo/core-auth": "workspace:*",
    "drizzle-orm": "^0.45.1"
  },
  "devDependencies": {
    "@repo/typescript-config": "workspace:*",
    "typescript": "^5.6.0"
  }
}

2.3 packages/mcp-core/tsconfig.json

{
  "extends": "@repo/typescript-config/base.json",
  "include": ["src/**/*.ts"]
}

2.4 packages/mcp-core/src/types.ts

/**
 * Shared MCP types used by mcp-gateway and any service that exposes MCP tools.
 *
 * A descriptor is a *declarative* tool definition. It contains no executable
 * logic — just metadata. The mcp-gateway uses the descriptor to advertise the
 * tool to AI clients and to know which internal endpoint to proxy to.
 */
 
export interface McpToolDescriptor {
  /** Tool name within the namespace, e.g. "list_posts" (not "blog.list_posts"). */
  name: string
 
  /** One-sentence description shown to the AI agent. */
  description: string
 
  /** JSON Schema for the tool's input. */
  inputSchema: {
    type: "object"
    required?: string[]
    properties: Record<string, McpJsonSchemaProperty>
  }
 
  /** Internal endpoint mcp-gateway proxies to, format: "METHOD /path". */
  internalRoute: string
 
  /** PBAC permission string required to call this tool. */
  requiredPermission: string
 
  /** Optional plan gate — undefined means available on all plans. */
  requiredPlan?: "free" | "starter" | "growth" | "scale"
}
 
export interface McpJsonSchemaProperty {
  type: "string" | "number" | "boolean" | "array" | "object"
  description?: string
  enum?: readonly string[]
  items?: McpJsonSchemaProperty | { type: string }
  properties?: Record<string, McpJsonSchemaProperty>
}
 
/** Per-request context built by mcp-gateway auth layer. */
export interface McpContext {
  tenantId: string
  apiKeyId: string
  permissions: string[]
  plan: "free" | "starter" | "growth" | "scale"
  agentId?: string
  requestId: string
}
 
/** Standard error envelope returned to MCP clients. */
export interface McpError {
  code: McpErrorCode
  message: string
  details?: Record<string, unknown>
}
 
export type McpErrorCode =
  | "invalid_api_key"
  | "permission_denied"
  | "plan_required"
  | "rate_limited"
  | "tool_not_found"
  | "invalid_input"
  | "service_error"
  | "internal_error"

2.5 packages/mcp-core/src/auth.ts

/**
 * Shared API key validation logic.
 *
 * Used by both apps/gateway and apps/mcp-gateway so the validation rules,
 * cache strategy, and permission resolution are identical.
 *
 * Cache strategy:
 *   - KV key:   ak:{sha256(key)}
 *   - TTL:      5 minutes (300s)
 *   - Value:    JSON-serialized cached row
 *   - Miss:     fall through to DB, write-through on success
 *
 * Why 5 minutes? Long enough to absorb agent bursts (typical agent session
 * fires 10-50 tool calls within seconds). Short enough that permission
 * changes propagate without manual cache invalidation in most cases.
 */
 
import { createDb, apiKeys, eq } from "@repo/core-database"
import { hashSHA256 } from "@repo/core-auth"
import type { McpContext, McpError } from "./types"
 
const CACHE_TTL_SECONDS = 300
 
interface CachedKey {
  id: string
  tenantId: string
  permissions: string[]
  status: string
  expiresAt: string | null
}
 
export interface ValidateOptions {
  databaseUrl: string
  kv: KVNamespace
  agentId?: string
}
 
export type ValidateResult =
  | { ok: true; context: McpContext }
  | { ok: false; error: McpError }
 
export async function validateApiKey(
  rawKey: string,
  opts: ValidateOptions,
): Promise<ValidateResult> {
  const hash = await hashSHA256(rawKey)
  const cacheKey = `ak:${hash}`
 
  let cached: CachedKey | null = null
 
  // 1. Cache lookup
  const cachedRaw = await opts.kv.get(cacheKey)
  if (cachedRaw) {
    try {
      cached = JSON.parse(cachedRaw) as CachedKey
    } catch {
      cached = null
    }
  }
 
  // 2. Cache miss → DB lookup
  if (!cached) {
    const db = createDb(opts.databaseUrl)
    const record = await db.query.apiKeys.findFirst({
      where: eq(apiKeys.keyHash, hash),
    })
 
    if (!record) {
      return { ok: false, error: { code: "invalid_api_key", message: "API key not found" } }
    }
 
    cached = {
      id: record.id,
      tenantId: record.tenantId,
      permissions: record.permissions as string[],
      status: record.status,
      expiresAt: record.expiresAt ? record.expiresAt.toISOString() : null,
    }
 
    // Write through to cache (non-blocking — caller can use ctx.waitUntil)
    await opts.kv.put(cacheKey, JSON.stringify(cached), {
      expirationTtl: CACHE_TTL_SECONDS,
    })
  }
 
  // 3. Validate
  if (cached.status !== "active") {
    return { ok: false, error: { code: "invalid_api_key", message: "API key is not active" } }
  }
  if (cached.expiresAt && new Date(cached.expiresAt) < new Date()) {
    return { ok: false, error: { code: "invalid_api_key", message: "API key has expired" } }
  }
 
  return {
    ok: true,
    context: {
      tenantId: cached.tenantId,
      apiKeyId: cached.id,
      permissions: cached.permissions,
      // Plan resolution happens separately — auth.ts in mcp-gateway adds it
      plan: "free",
      agentId: opts.agentId,
      requestId: crypto.randomUUID(),
    },
  }
}
 
/** Check whether a permission string is satisfied by a context's permission set. */
export function hasPermission(ctx: McpContext, required: string): boolean {
  return ctx.permissions.includes(required)
}

2.6 packages/mcp-core/src/errors.ts

import type { McpError, McpErrorCode } from "./types"
 
export function mcpError(code: McpErrorCode, message: string, details?: Record<string, unknown>): McpError {
  return { code, message, details }
}

2.7 packages/mcp-core/src/index.ts

export type {
  McpToolDescriptor,
  McpJsonSchemaProperty,
  McpContext,
  McpError,
  McpErrorCode,
} from "./types"
 
export { validateApiKey, hasPermission } from "./auth"
export type { ValidateOptions, ValidateResult } from "./auth"
export { mcpError } from "./errors"

2.8 Verify

cd packages/mcp-core
npm install
npm run check-types  # should pass with zero errors

3. Step 2 — apps/mcp-gateway Scaffold

3.1 Create directory

mkdir -p apps/mcp-gateway/src/transports
mkdir -p apps/mcp-gateway/src/resources
cd apps/mcp-gateway

3.2 apps/mcp-gateway/package.json

{
  "name": "mcp-gateway",
  "version": "0.0.1",
  "private": true,
  "main": "src/index.ts",
  "scripts": {
    "dev": "wrangler dev --env-file=.env",
    "dev:stdio": "tsx src/transports/stdio.ts",
    "deploy": "wrangler deploy --minify",
    "build:stdio": "tsc -p tsconfig.stdio.json",
    "check-types": "tsc --noEmit"
  },
  "devDependencies": {
    "@cloudflare/workers-types": "^4.20260203.0",
    "@repo/core-database": "workspace:*",
    "@repo/core-auth": "workspace:*",
    "@repo/core-types": "workspace:*",
    "@repo/mcp-core": "workspace:*",
    "@repo/typescript-config": "workspace:*",
    "tsx": "^4.19.0"
  },
  "dependencies": {
    "@modelcontextprotocol/sdk": "^1.0.0",
    "hono": "^4.11.5",
    "wrangler": "^4.61.1"
  }
}

Notes:

  • @modelcontextprotocol/sdk lives only here. Never in product services or apps/gateway.
  • dev:stdio runs the stdio transport directly via tsx for local dev — no build step needed.
  • build:stdio is for shipping a pre-built .js file referenced by .mcp.json.

3.3 apps/mcp-gateway/wrangler.toml

name = "vlozi-mcp-gateway"
main = "src/index.ts"
compatibility_date = "2024-04-01"
compatibility_flags = ["nodejs_compat"]
 
routes = [
  { pattern = "mcp.vlozi.app", custom_domain = true }
]
 
[dev]
port = 8789  # 8788 is taken by apps/gateway, 8791 by blog-service
 
# Service bindings — same product Workers as apps/gateway, so a tool call
# from an AI agent reaches the same backend services as a REST call.
[[services]]
binding = "BLOG_SERVICE"
service = "logicspike-blog-service"
 
[[services]]
binding = "MEDIA_SERVICE"
service = "logicspike-media"
 
[[services]]
binding = "BRAIN_SERVICE"
service = "logicspike-brain-service"
 
[[services]]
binding = "CI_SERVICE"
service = "logicspike-contact-intelligence"
 
[[services]]
binding = "NL_SERVICE"
service = "logicspike-newsletter"
 
[[services]]
binding = "CONTENT_ENGINE_SERVICE"
service = "logicspike-content-engine"
 
[[services]]
binding = "CE_SERVICE"
service = "logicspike-chat-engine"
 
# Shared KV with apps/gateway — backs:
#   - ak:{hash}      API-key cache (5min TTL)
#   - rl:mcp:{key}   MCP-specific rate-limit counters
#
# Same namespace ID as apps/gateway/wrangler.toml.
[[kv_namespaces]]
binding = "RATE_LIMIT_KV"
id = "ec19e516e3d5438bbed769298d894e7b"
preview_id = "4f5d9b711b1647558d73ab51a68ce725"
 
# Secrets — set via wrangler secret put or .dev.vars:
#   GATEWAY_SECRET     — internal handshake with product Workers
#   DATABASE_URL       — Neon Postgres for API key DB lookups on cache miss
#   JWT_PUBLIC_KEY     — currently unused; reserved for future JWT support
[vars]
# No secrets here.

3.4 apps/mcp-gateway/tsconfig.json

{
  "extends": "@repo/typescript-config/base.json"
}

3.5 apps/mcp-gateway/tsconfig.stdio.json

For building the stdio entrypoint as plain Node.js (not a Cloudflare Worker):

{
  "extends": "./tsconfig.json",
  "compilerOptions": {
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "target": "ES2022",
    "outDir": "dist",
    "rootDir": "src",
    "declaration": false,
    "sourceMap": true
  },
  "include": ["src/**/*.ts"]
}

3.6 apps/mcp-gateway/.dev.vars

(gitignored — create locally, never commit)

GATEWAY_SECRET=<copy from apps/gateway/.dev.vars>
DATABASE_URL=<same Neon URL apps/gateway uses>

3.7 Verify

cd apps/mcp-gateway
npm install
npm run check-types  # will fail until src files exist — that's fine for now

4. Step 3 — apps/mcp-gateway/src/types.ts

The Cloudflare Worker bindings interface.

import type { Fetcher } from "@cloudflare/workers-types"
 
export type ServiceBinding =
  | "BLOG_SERVICE"
  | "MEDIA_SERVICE"
  | "BRAIN_SERVICE"
  | "CI_SERVICE"
  | "NL_SERVICE"
  | "CONTENT_ENGINE_SERVICE"
  | "CE_SERVICE"
 
export interface McpGatewayBindings {
  // Service bindings — keep in sync with wrangler.toml
  BLOG_SERVICE: Fetcher
  MEDIA_SERVICE: Fetcher
  BRAIN_SERVICE: Fetcher
  CI_SERVICE: Fetcher
  NL_SERVICE: Fetcher
  CONTENT_ENGINE_SERVICE: Fetcher
  CE_SERVICE: Fetcher
 
  // KV
  RATE_LIMIT_KV: KVNamespace
 
  // Secrets
  GATEWAY_SECRET: string
  DATABASE_URL: string
}

5. Step 4 — apps/mcp-gateway/src/auth.ts

Wraps packages/mcp-core auth and adds plan resolution.

import { validateApiKey, type McpContext, type McpError } from "@repo/mcp-core"
import type { McpGatewayBindings } from "./types"
 
export interface ResolveAuthArgs {
  apiKey: string
  agentId?: string
  env: McpGatewayBindings
  ctx: ExecutionContext
}
 
export async function resolveAuth(
  args: ResolveAuthArgs,
): Promise<{ ok: true; context: McpContext } | { ok: false; error: McpError }> {
  const result = await validateApiKey(args.apiKey, {
    databaseUrl: args.env.DATABASE_URL,
    kv: args.env.RATE_LIMIT_KV,
    agentId: args.agentId,
  })
 
  if (!result.ok) return result
 
  // TODO Phase 3: load plan from subscriptions table via short-TTL KV cache.
  // For Phase 1 (dev tooling), all tools are available — plan = "scale".
  result.context.plan = "scale"
 
  return result
}

6. Step 5 — apps/mcp-gateway/src/router.ts

The namespace-to-service-binding map. This is the single source of truth for which Worker handles each tool namespace.

import type { Fetcher } from "@cloudflare/workers-types"
import type { McpGatewayBindings } from "./types"
 
export type Namespace =
  | "blog"
  | "brain"
  | "contacts"
  | "newsletter"
  | "content"
  | "media"
  | "chat"
 
const NAMESPACE_TO_BINDING: Record<Namespace, keyof McpGatewayBindings> = {
  blog: "BLOG_SERVICE",
  brain: "BRAIN_SERVICE",
  contacts: "CI_SERVICE",
  newsletter: "NL_SERVICE",
  content: "CONTENT_ENGINE_SERVICE",
  media: "MEDIA_SERVICE",
  chat: "CE_SERVICE",
}
 
export function resolveBinding(
  env: McpGatewayBindings,
  namespace: Namespace,
): Fetcher {
  const bindingKey = NAMESPACE_TO_BINDING[namespace]
  const binding = env[bindingKey]
  if (!binding) {
    throw new Error(`Service binding missing: ${bindingKey} for namespace ${namespace}`)
  }
  return binding as Fetcher
}
 
/**
 * Splits a fully-qualified tool name into [namespace, toolName].
 *
 * "blog.list_posts" → ["blog", "list_posts"]
 */
export function splitToolName(fullName: string): [Namespace, string] | null {
  const idx = fullName.indexOf(".")
  if (idx < 0) return null
  const ns = fullName.slice(0, idx) as Namespace
  const name = fullName.slice(idx + 1)
  if (!(ns in NAMESPACE_TO_BINDING)) return null
  return [ns, name]
}

7. Step 6 — apps/mcp-gateway/src/server.ts

The core MCP server. Assembles tool descriptors from each product, handles tool calls.

import {
  hasPermission,
  mcpError,
  type McpContext,
  type McpToolDescriptor,
} from "@repo/mcp-core"
import type { McpGatewayBindings } from "./types"
import { resolveBinding, splitToolName, type Namespace } from "./router"
 
/**
 * Tool descriptors live in product services but cannot be imported directly
 * (Rule 7.2). At build time we mirror the descriptor arrays here. When a new
 * tool is added in a service, the dev also updates this registry.
 *
 * Alternative considered: a `mcp-descriptors` package that each service writes
 * to. Rejected because it splits a tool definition across two files. Keeping
 * the source of truth in the service's mcp/index.ts and mirroring here means
 * one place to look during code review.
 */
import { tools as blogTools } from "./registries/blog.registry"
 
const ALL_TOOLS: Record<Namespace, McpToolDescriptor[]> = {
  blog: blogTools,
  brain: [],     // Phase 1: blog only
  contacts: [],  // Phase 2
  newsletter: [],
  content: [],
  media: [],
  chat: [],
}
 
export interface ListToolsArgs {
  ctx: McpContext
}
 
/** Return the tools available to this context (filtered by permission + plan). */
export function listTools(args: ListToolsArgs): Array<{ name: string; description: string; inputSchema: unknown }> {
  const out: Array<{ name: string; description: string; inputSchema: unknown }> = []
  for (const [ns, tools] of Object.entries(ALL_TOOLS) as [Namespace, McpToolDescriptor[]][]) {
    for (const tool of tools) {
      if (!hasPermission(args.ctx, tool.requiredPermission)) continue
      // Plan gating skipped in Phase 1 (all tools open for dev).
      out.push({
        name: `${ns}.${tool.name}`,
        description: tool.description,
        inputSchema: tool.inputSchema,
      })
    }
  }
  return out
}
 
export interface CallToolArgs {
  fullName: string
  input: Record<string, unknown>
  ctx: McpContext
  env: McpGatewayBindings
}
 
export async function callTool(
  args: CallToolArgs,
): Promise<{ ok: true; data: unknown } | { ok: false; error: ReturnType<typeof mcpError> }> {
  const split = splitToolName(args.fullName)
  if (!split) {
    return { ok: false, error: mcpError("tool_not_found", `Unknown tool: ${args.fullName}`) }
  }
  const [namespace, toolName] = split
 
  const tool = ALL_TOOLS[namespace].find((t) => t.name === toolName)
  if (!tool) {
    return { ok: false, error: mcpError("tool_not_found", `Unknown tool: ${args.fullName}`) }
  }
 
  if (!hasPermission(args.ctx, tool.requiredPermission)) {
    return {
      ok: false,
      error: mcpError("permission_denied", `Missing permission: ${tool.requiredPermission}`),
    }
  }
 
  // Parse "METHOD /path" from descriptor.
  const [method, path] = tool.internalRoute.split(" ")
  if (!method || !path) {
    return { ok: false, error: mcpError("internal_error", `Invalid internalRoute on tool ${args.fullName}`) }
  }
 
  const binding = resolveBinding(args.env, namespace)
 
  const headers = new Headers()
  headers.set("content-type", "application/json")
  headers.set("x-gateway-key", args.env.GATEWAY_SECRET)
  headers.set("x-tenant-id", args.ctx.tenantId)
  headers.set("x-api-key-id", args.ctx.apiKeyId)
  headers.set("x-request-id", args.ctx.requestId)
  if (args.ctx.agentId) headers.set("x-agent-id", args.ctx.agentId)
 
  const url = new URL(path, "https://internal.invalid")
  // For GET, encode input as query string; for POST/PATCH/PUT, send body.
  const isBodyless = method === "GET" || method === "HEAD"
  if (isBodyless) {
    for (const [k, v] of Object.entries(args.input)) {
      if (v != null) url.searchParams.set(k, String(v))
    }
  }
 
  const response = await (binding as unknown as { fetch(u: string, init: unknown): Promise<Response> }).fetch(
    url.toString(),
    {
      method,
      headers,
      body: isBodyless ? null : JSON.stringify(args.input),
    },
  )
 
  if (!response.ok) {
    const text = await response.text()
    return {
      ok: false,
      error: mcpError("service_error", `Service returned ${response.status}`, { body: text }),
    }
  }
 
  const data = (await response.json()) as { data?: unknown; error?: unknown }
  if (data.error) {
    return { ok: false, error: mcpError("service_error", String(data.error)) }
  }
 
  return { ok: true, data: data.data }
}

7.1 apps/mcp-gateway/src/registries/blog.registry.ts

Mirrors the blog service's tool descriptors. Updated when blog adds a tool.

import type { McpToolDescriptor } from "@repo/mcp-core"
 
export const tools: McpToolDescriptor[] = [
  {
    name: "list_posts",
    description: "List blog posts for the current workspace. Supports filtering by status, tag, and date range.",
    inputSchema: {
      type: "object",
      properties: {
        status: { type: "string", enum: ["draft", "published", "scheduled"], description: "Filter by post status" },
        limit: { type: "number", description: "Max results (default 20, max 100)" },
        cursor: { type: "string", description: "Pagination cursor from previous response" },
      },
    },
    internalRoute: "GET /mcp/tools/list-posts",
    requiredPermission: "blog:posts.read",
  },
  {
    name: "create_draft",
    description: "Create a new blog post draft.",
    inputSchema: {
      type: "object",
      required: ["title"],
      properties: {
        title: { type: "string" },
        content: { type: "string", description: "MDX content body" },
        tags: { type: "array", items: { type: "string" } },
        slug: { type: "string", description: "URL slug (auto-generated from title if omitted)" },
      },
    },
    internalRoute: "POST /mcp/tools/create-draft",
    requiredPermission: "blog:posts.write",
  },
  // Add list_tags, search_posts, etc. as you build them.
]

8. Step 7 — apps/mcp-gateway/src/transports/stdio.ts

Phase 1 ships only the stdio transport.

#!/usr/bin/env node
/**
 * Stdio MCP transport — for local dev (Claude Code, Cursor, Claude Desktop).
 *
 * Reads:
 *   - MCP_API_KEY      from env (dev API key with all scopes)
 *   - MCP_TENANT_ID    optional, for tools that don't carry tenant inference
 *   - MCP_AGENT_ID     optional, identifies the calling agent
 *   - LOCAL_BLOG_URL   e.g. http://localhost:8791 — points to running dev services
 *
 * Unlike the Worker entry point, this script runs in Node.js and reaches
 * product services over HTTP at their local dev ports. Service bindings
 * don't exist outside Cloudflare's runtime.
 */
 
import { Server } from "@modelcontextprotocol/sdk/server/index.js"
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"
import {
  CallToolRequestSchema,
  ListToolsRequestSchema,
} from "@modelcontextprotocol/sdk/types.js"
import { tools as blogTools } from "../registries/blog.registry"
import type { McpToolDescriptor } from "@repo/mcp-core"
 
const API_KEY = process.env.MCP_API_KEY
const TENANT_ID = process.env.MCP_TENANT_ID ?? ""
const AGENT_ID = process.env.MCP_AGENT_ID
const GATEWAY_SECRET = process.env.GATEWAY_SECRET ?? ""
 
if (!API_KEY) {
  console.error("MCP_API_KEY not set — refusing to start")
  process.exit(1)
}
 
const SERVICE_URLS: Record<string, string> = {
  blog: process.env.LOCAL_BLOG_URL ?? "http://localhost:8791",
  brain: process.env.LOCAL_BRAIN_URL ?? "http://localhost:8792",
  // ...
}
 
const ALL_TOOLS: Record<string, McpToolDescriptor[]> = {
  blog: blogTools,
}
 
const server = new Server(
  { name: "vlozi-mcp", version: "0.1.0" },
  { capabilities: { tools: {} } },
)
 
server.setRequestHandler(ListToolsRequestSchema, async () => {
  const tools: Array<{ name: string; description: string; inputSchema: unknown }> = []
  for (const [ns, descriptors] of Object.entries(ALL_TOOLS)) {
    for (const t of descriptors) {
      tools.push({
        name: `${ns}.${t.name}`,
        description: t.description,
        inputSchema: t.inputSchema,
      })
    }
  }
  return { tools }
})
 
server.setRequestHandler(CallToolRequestSchema, async (req) => {
  const fullName = req.params.name
  const input = (req.params.arguments ?? {}) as Record<string, unknown>
 
  const idx = fullName.indexOf(".")
  if (idx < 0) throw new Error(`Invalid tool name: ${fullName}`)
  const ns = fullName.slice(0, idx)
  const name = fullName.slice(idx + 1)
 
  const tool = ALL_TOOLS[ns]?.find((t) => t.name === name)
  if (!tool) throw new Error(`Unknown tool: ${fullName}`)
 
  const baseUrl = SERVICE_URLS[ns]
  if (!baseUrl) throw new Error(`No local URL configured for namespace ${ns}`)
 
  const [method, path] = tool.internalRoute.split(" ")
  const url = new URL(path, baseUrl)
  const isBodyless = method === "GET" || method === "HEAD"
  if (isBodyless) {
    for (const [k, v] of Object.entries(input)) {
      if (v != null) url.searchParams.set(k, String(v))
    }
  }
 
  const res = await fetch(url.toString(), {
    method,
    headers: {
      "content-type": "application/json",
      "x-api-key": API_KEY,
      "x-gateway-key": GATEWAY_SECRET,
      "x-tenant-id": TENANT_ID,
      ...(AGENT_ID ? { "x-agent-id": AGENT_ID } : {}),
    },
    body: isBodyless ? undefined : JSON.stringify(input),
  })
 
  const text = await res.text()
  if (!res.ok) {
    throw new Error(`${ns}.${name} returned ${res.status}: ${text}`)
  }
 
  return {
    content: [{ type: "text", text }],
  }
})
 
const transport = new StdioServerTransport()
await server.connect(transport)

9. Step 8 — apps/mcp-gateway/src/index.ts

The Worker entry. Phase 1 ships only a health endpoint. HTTP/SSE comes in Phase 3.

import { Hono } from "hono"
import type { McpGatewayBindings } from "./types"
 
const app = new Hono<{ Bindings: McpGatewayBindings }>()
 
app.get("/health", (c) =>
  c.json({
    status: "ok",
    transport: "http-sse",
    env: {
      BLOG_SERVICE: !!c.env.BLOG_SERVICE,
      BRAIN_SERVICE: !!c.env.BRAIN_SERVICE,
      CI_SERVICE: !!c.env.CI_SERVICE,
      NL_SERVICE: !!c.env.NL_SERVICE,
      CONTENT_ENGINE_SERVICE: !!c.env.CONTENT_ENGINE_SERVICE,
      MEDIA_SERVICE: !!c.env.MEDIA_SERVICE,
      CE_SERVICE: !!c.env.CE_SERVICE,
      RATE_LIMIT_KV: !!c.env.RATE_LIMIT_KV,
      DATABASE_URL: !!c.env.DATABASE_URL,
      GATEWAY_SECRET: !!c.env.GATEWAY_SECRET,
    },
  }),
)
 
// Phase 3 will add:
//   app.get("/sse", sseHandler)
//   app.post("/message", messageHandler)
 
app.onError((err, c) => {
  console.error("mcp-gateway error:", err)
  return c.json({ error: { code: "internal_error", message: err.message } }, 500)
})
 
export default app

10. Step 9 — Blog Service MCP Routes

The blog service exposes internal MCP endpoints that only mcp-gateway can reach.

10.1 apps/blog-service/src/mcp/index.ts

import type { McpToolDescriptor } from "@repo/mcp-core"
 
/**
 * Authoritative tool descriptors for the blog service.
 *
 * When you add a tool here:
 *   1. Implement the handler in src/mcp/tools/<name>.ts
 *   2. Register the handler in src/mcp/routes.ts
 *   3. Mirror the descriptor in apps/mcp-gateway/src/registries/blog.registry.ts
 *
 * Step 3 will eventually be automated, but for Phase 1 it's a manual mirror.
 */
 
export const namespace = "blog"
 
export const tools: McpToolDescriptor[] = [
  // Keep in sync with apps/mcp-gateway/src/registries/blog.registry.ts
]

10.2 apps/blog-service/src/mcp/routes.ts

import { Hono } from "hono"
import type { Context, MiddlewareHandler } from "hono"
 
const verifyGatewayKey: MiddlewareHandler = async (c, next) => {
  const key = c.req.header("x-gateway-key")
  if (!key || key !== c.env.GATEWAY_SECRET) {
    return c.json({ error: "forbidden" }, 403)
  }
  await next()
}
 
export const mcpRoutes = new Hono<{ Bindings: { GATEWAY_SECRET: string; BLOG_DATABASE_URL: string } }>()
 
mcpRoutes.use("/*", verifyGatewayKey)
 
// GET /mcp/tools/list-posts
mcpRoutes.get("/tools/list-posts", async (c) => {
  const tenantId = c.req.header("x-tenant-id")
  if (!tenantId) return c.json({ data: null, error: "missing tenant" }, 400)
 
  const status = c.req.query("status") as "draft" | "published" | "scheduled" | undefined
  const limit = Math.min(Number(c.req.query("limit") ?? 20), 100)
  const cursor = c.req.query("cursor")
 
  // TODO: replace with real query — this is just the shape
  const posts: unknown[] = []
 
  return c.json({ data: { posts, nextCursor: null }, error: null })
})
 
// POST /mcp/tools/create-draft
mcpRoutes.post("/tools/create-draft", async (c) => {
  const tenantId = c.req.header("x-tenant-id")
  if (!tenantId) return c.json({ data: null, error: "missing tenant" }, 400)
 
  const body = (await c.req.json()) as { title: string; content?: string; tags?: string[]; slug?: string }
  if (!body.title) return c.json({ data: null, error: "title is required" }, 400)
 
  // TODO: real insert
  const post = { id: crypto.randomUUID(), title: body.title, status: "draft" as const }
 
  return c.json({ data: { post }, error: null })
})

10.3 Wire into apps/blog-service/src/index.ts

Add to the existing Hono app:

import { mcpRoutes } from "./mcp/routes"
// ...
app.route("/mcp", mcpRoutes)

That's it. The blog service now answers /mcp/tools/* only when called with a valid x-gateway-key header.


11. Step 11 — .mcp.json

Update the root .mcp.json so Claude Code picks up the new stdio server:

{
  "mcpServers": {
    "code-review-graph": {
      "command": "code-review-graph",
      "args": ["serve"],
      "type": "stdio"
    },
    "vlozi": {
      "command": "node",
      "args": ["--import", "tsx", "./apps/mcp-gateway/src/transports/stdio.ts"],
      "type": "stdio",
      "env": {
        "MCP_API_KEY": "${VLOZI_DEV_API_KEY}",
        "MCP_TENANT_ID": "${VLOZI_DEV_TENANT_ID}",
        "GATEWAY_SECRET": "${VLOZI_DEV_GATEWAY_SECRET}",
        "LOCAL_BLOG_URL": "http://localhost:8791"
      }
    }
  }
}

The --import tsx flag lets Node.js execute the TypeScript file directly without a build step. Once stable, switch to running apps/mcp-gateway/dist/transports/stdio.js after npm run build:stdio.


12. Step 12 — End-to-End Smoke Test

12.1 Set up local env

# In your shell profile (or a .env file you source)
export VLOZI_DEV_API_KEY=ls_dev_xxxxxxxx
export VLOZI_DEV_TENANT_ID=tenant_xxxx
export VLOZI_DEV_GATEWAY_SECRET=<copy from apps/blog-service/.dev.vars>

12.2 Start the blog service

turbo dev --filter=blog-service
# Verify: curl http://localhost:8791/mcp/tools/list-posts -H "x-gateway-key: $VLOZI_DEV_GATEWAY_SECRET" -H "x-tenant-id: $VLOZI_DEV_TENANT_ID"
# Expect: 200 with {"data":{"posts":[],"nextCursor":null},"error":null}

If you get 403, the GATEWAY_SECRET doesn't match. If you get 200 with empty posts, the route is wired correctly.

12.3 Restart Claude Code

Reload the workspace. The vlozi MCP server should appear in the tool picker.

12.4 Verify in Claude Code

You: what vlozi tools are available?
Claude: [lists blog.list_posts, blog.create_draft]
 
You: list my blog posts
Claude: [calls blog.list_posts, returns empty list]

If Claude returns errors, check:

Error Cause
MCP_API_KEY not set The .mcp.json env vars aren't being resolved by your shell
403 forbidden on tool call GATEWAY_SECRET mismatch between blog-service and stdio env
connection refused blog-service isn't running on localhost:8791
Tool list empty blog.registry.ts in mcp-gateway has empty array

13. Definition of Done — Phase 1

Phase 1 is complete when all of the following are true:

  • packages/mcp-core compiles with no errors and is consumed by apps/mcp-gateway
  • apps/mcp-gateway/src/index.ts deploys to a Cloudflare preview without errors (Worker code compiles)
  • apps/mcp-gateway/src/transports/stdio.ts runs locally via tsx and connects to Claude Code
  • apps/blog-service/src/mcp/routes.ts answers requests with valid x-gateway-key
  • apps/blog-service/src/mcp/routes.ts returns 403 without x-gateway-key
  • Claude Code can call blog.list_posts and blog.create_draft end-to-end
  • .mcp.json is committed (without secrets — secrets stay in env vars)
  • All 9 blog tools listed in architecture.md §7.1 are implemented (descriptors + handlers)
  • Brain service mirrors the same pattern with all 5 tools from architecture.md §7.2

14. Phase 2 — Adding Other Products (Recipe)

For each remaining product (contacts, newsletter, content, media, chat), repeat:

  1. Add apps/<product>/src/mcp/index.ts with McpToolDescriptor[] array
  2. Add apps/<product>/src/mcp/routes.ts with verifyGatewayKey middleware + handlers
  3. Wire app.route("/mcp", mcpRoutes) into apps/<product>/src/index.ts
  4. Create apps/mcp-gateway/src/registries/<product>.registry.ts mirroring the descriptors
  5. Import the registry in apps/mcp-gateway/src/server.ts and populate ALL_TOOLS[<namespace>]
  6. Add an entry to SERVICE_URLS in apps/mcp-gateway/src/transports/stdio.ts for local dev
  7. Test end-to-end through Claude Code

Time estimate per product: 2–4 hours for descriptor + 4–8 hours per tool's real business logic (depending on complexity).


15. Phase 3 — HTTP/SSE Transport (Recipe)

When ready to ship mcp.vlozi.app to customers:

  1. Create apps/mcp-gateway/src/transports/http-sse.ts using the MCP SDK's SSEServerTransport
  2. Wire /sse (GET) and /message (POST) routes into src/index.ts
  3. Add per-plan filtering in auth.ts — load subscription from DB with 5min KV cache
  4. Set up mcp.vlozi.app DNS pointing to the Worker
  5. Add rate limit middleware: 120 calls/min/tenant, 60 calls/min/key
  6. Add MCP connection UI in seller dashboard (generate connection string with API key)
  7. Write public docs at docs.vlozi.app/mcp
  8. Run a 1-day soak test with internal dogfooding

16. Operational Notes

16.1 Bundle size watch

Add to CI:

# After build, fail if mcp-gateway exceeds 800KB (gives 200KB headroom over Workers 1MB limit)
test $(stat -c%s apps/mcp-gateway/dist/index.js) -lt 819200

16.2 Cache invalidation

When an API key is revoked or its permissions change, the 5-minute KV cache means changes take up to 5 minutes to propagate. For immediate revocation, manager-service must KV.delete("ak:" + sha256(key)) after the DB update.

Add this to the API key revocation handler in manager-service:

await env.RATE_LIMIT_KV.delete(`ak:${hash}`)

16.3 Logging

Every tool call should log:

{
  "ts": "2026-05-14T10:23:45.123Z",
  "tenantId": "tenant_xxx",
  "apiKeyId": "key_xxx",
  "agentId": "claude-code",
  "tool": "blog.list_posts",
  "durationMs": 87,
  "status": "ok"
}

In Phase 1 use console.log with JSON; in Phase 3 send to log-db.

16.4 Local dev gotchas

  • wrangler dev won't run two Workers on the same port — apps/gateway (8788), apps/mcp-gateway (8789), each service on its own port
  • Service bindings DON'T work in wrangler dev across separate processes by default — use wrangler dev --remote or set up Miniflare to share state. For Phase 1, stdio transport bypasses this entirely.
  • The KV namespace is local-only in wrangler dev — entries don't persist across restarts

17. References

MCP (Model Context Protocol)