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/gatewayis running locally withwrangler devon port 8788 -
apps/blog-serviceis running locally withwrangler devon port 8791 - A dev API key exists with
blog:*scopes (generate via seller dashboard) - Cloudflare account access — you can run
wrangler loginand deploy - You have the
RATE_LIMIT_KVnamespace 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 testAfter 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-core2.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 errors3. 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-gateway3.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/sdklives only here. Never in product services orapps/gateway.dev:stdioruns the stdio transport directly viatsxfor local dev — no build step needed.build:stdiois for shipping a pre-built.jsfile 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 now4. 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 app10. 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-corecompiles with no errors and is consumed byapps/mcp-gateway -
apps/mcp-gateway/src/index.tsdeploys to a Cloudflare preview without errors (Worker code compiles) -
apps/mcp-gateway/src/transports/stdio.tsruns locally viatsxand connects to Claude Code -
apps/blog-service/src/mcp/routes.tsanswers requests with validx-gateway-key -
apps/blog-service/src/mcp/routes.tsreturns 403 withoutx-gateway-key - Claude Code can call
blog.list_postsandblog.create_draftend-to-end -
.mcp.jsonis 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:
- Add
apps/<product>/src/mcp/index.tswithMcpToolDescriptor[]array - Add
apps/<product>/src/mcp/routes.tswithverifyGatewayKeymiddleware + handlers - Wire
app.route("/mcp", mcpRoutes)intoapps/<product>/src/index.ts - Create
apps/mcp-gateway/src/registries/<product>.registry.tsmirroring the descriptors - Import the registry in
apps/mcp-gateway/src/server.tsand populateALL_TOOLS[<namespace>] - Add an entry to
SERVICE_URLSinapps/mcp-gateway/src/transports/stdio.tsfor local dev - 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:
- Create
apps/mcp-gateway/src/transports/http-sse.tsusing the MCP SDK'sSSEServerTransport - Wire
/sse(GET) and/message(POST) routes intosrc/index.ts - Add per-plan filtering in
auth.ts— load subscription from DB with 5min KV cache - Set up
mcp.vlozi.appDNS pointing to the Worker - Add rate limit middleware: 120 calls/min/tenant, 60 calls/min/key
- Add MCP connection UI in seller dashboard (generate connection string with API key)
- Write public docs at
docs.vlozi.app/mcp - 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 81920016.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 devwon'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 devacross separate processes by default — usewrangler dev --remoteor 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
- architecture.md — high-level design and decision log
- Cloudflare Service Bindings docs
- MCP SDK reference
- apps/gateway/wrangler.toml — the pattern to mirror
- apps/blog-service/wrangler.toml — example product Worker