Last Updated: 2026-05-14 Status: Planning — Phase 1 not yet started Owner: Platform Team
This document is the single source of truth for how Vlozi designs, builds, and ships MCP (Model Context Protocol) servers across all products. Read this before writing a single line of MCP code.
1. What is MCP and Why We're Building It
MCP is a standard protocol that lets an AI agent (Claude, Cursor, a customer's autonomous agent) call tools you expose — structured functions with typed inputs and outputs. It's like a REST API, except the caller is an AI, not a browser.
Without MCP, an AI agent working with Vlozi has to navigate our REST APIs raw — it must know URL paths, auth headers, and response shapes. With MCP, the agent calls blog.list_drafts and gets back structured data. The AI doesn't need to know anything about our internals.
Why this matters for Vlozi specifically:
- The AI Brain and Contact Intelligence are only as powerful as the tools available to them. MCP is the connective tissue.
- Customers will want to connect their own AI agents (Claude, GPT, custom) to their Vlozi workspace. MCP is the standard interface.
- Our own dev tooling (Claude Code, Cursor) gets dramatically better when it can call Vlozi tools directly during development.
2. Architecture Decision: Dedicated MCP Gateway
We evaluated three patterns. We are implementing Option D: Dedicated apps/mcp-gateway Worker.
Why Not Option A (Single Central MCP Server)
A single vlozi-mcp package with all tools collapses when we need per-tenant tool filtering — a blog-only customer must not see brain.* tools.
Why Not Option B (Independent Per-Product MCP Servers)
Customers would need to configure N MCP servers in their AI client. Operational overhead is N times higher. Cross-product workflows require the AI to stitch across servers.
Why Not Option C (MCP Inside apps/gateway)
Mixing MCP into the existing REST gateway bloats its bundle with the MCP SDK (~400KB), inflating cold starts for every REST API call — including dashboard requests. MCP traffic (agent-driven, bursty, long-lived SSE connections) has a completely different profile from REST traffic (human-driven, short-lived). A runaway agent loop would compete with dashboard users for the same Worker. An MCP deploy bug could take down the REST gateway. They must be separate.
Option D — What We're Building
REST traffic → apps/gateway → product workers (unchanged, stays lean)
MCP traffic → apps/mcp-gateway → product workers (dedicated, MCP SDK lives here)
AI Agent (Claude / Cursor / Customer Agent)
│
│ Single MCP connection (mcp.vlozi.app)
▼
┌──────────────────────────────────────────────┐
│ apps/mcp-gateway │
│ │
│ • Authenticates the caller (API key) │
│ • Resolves tenant context │
│ • Filters tools by subscription plan │
│ • Routes call to the right product Worker │
│ • Logs every tool call for audit │
│ • stdio transport for dev tooling │
│ • HTTP/SSE transport for production │
└──────┬───────┬────────┬───────┬──────────────┘
│ │ │ │
blog-svc brain-svc CI-svc newsletter-svc
(internal)(internal)(internal)(internal)apps/mcp-gateway is the only external MCP surface. apps/gateway handles REST only — it never changes for MCP. Both workers share service bindings to the same product Workers and the same KV namespace.
Auth logic is shared via packages/mcp-core — no duplication between the two gateways.
3. Monorepo Structure
packages/
mcp-core/ ← NEW: shared MCP types, schema builders, auth helpers
src/
types.ts ← McpTool, McpResource, McpContext interfaces
schema.ts ← zod-to-MCP schema converter
auth.ts ← API key validation shared by mcp-gateway and tests
errors.ts ← standard MCP error shapes
package.json
apps/
gateway/ ← UNCHANGED — REST only, no MCP code ever goes here
mcp-gateway/ ← NEW dedicated MCP Worker
src/
index.ts ← Worker entry point — registers both transports
server.ts ← assembles all product tool registries into one MCP server
router.ts ← namespace routing: blog.* → BLOG_SERVICE binding
auth.ts ← wraps packages/mcp-core auth + plan-based tool filtering
transports/
http-sse.ts ← production transport (Cloudflare Workers SSE)
stdio.ts ← dev transport (local Node.js process)
resources/
workspace.ts ← workspace-level MCP resources
wrangler.toml ← service bindings to all product Workers + shared KV
package.json
blog-service/
src/
mcp/ ← blog MCP tools, co-located with the service
tools/
list-posts.ts
create-draft.ts
publish-post.ts
get-analytics.ts
index.ts ← exports: { namespace: 'blog', tools: [...] }
brain-service/
src/
mcp/
tools/
query.ts
ingest.ts
list-memories.ts
index.ts
contact-intelligence/
src/
mcp/
tools/
search-contacts.ts
get-contact-memory.ts
enrich-contact.ts
log-interaction.ts
index.ts
newsletter-service/
src/
mcp/
tools/
list-campaigns.ts
get-analytics.ts
create-campaign.ts
index.ts
content-engine/
src/
mcp/ ← already spec'd in docs/content-engine/mcp-server-spec.md
tools/
... ← migrate existing spec into this folder
index.ts
chat-engine/
src/
mcp/
tools/
list-sessions.ts
get-session.ts
index.ts
media-service/
src/
mcp/
tools/
upload.ts
list-assets.ts
search-assets.ts
index.tsRule: MCP tools live co-located with the service that owns the data. No standalone mcp-server apps. apps/mcp-gateway assembles them — it imports each service's mcp/index.ts registry.
Rule: apps/gateway never imports anything from apps/mcp-gateway and vice versa. They are independent Workers that share only packages/mcp-core.
Problem: apps/mcp-gateway cannot import from service apps (Rule 7.2). Solution: Each service's mcp/index.ts exports only tool descriptors (name, description, schema, handler URL) — no business logic. mcp-gateway proxies the actual execution to the service over the Cloudflare service binding.
4. Tool Descriptor Pattern
Every service exports its MCP tools as descriptors, not live functions. mcp-gateway resolves them.
// apps/blog-service/src/mcp/index.ts
import type { McpToolDescriptor } from '@vlozi/mcp-core'
export const namespace = 'blog'
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' },
},
},
// mcp-gateway calls this internal endpoint when the tool is invoked
internalRoute: 'GET /mcp/tools/list-posts',
// PBAC permission required
requiredPermission: 'blog:posts.read',
// which plan gates this tool (undefined = all plans)
requiredPlan: undefined,
},
{
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',
},
]The internalRoute is an endpoint the service exposes only to mcp-gateway (verified via x-gateway-key). mcp-gateway receives the MCP call, resolves the descriptor, injects tenant context, and proxies to the service over the service binding.
5. Transport Strategy
| Transport | When | Where |
|---|---|---|
stdio |
Dev tooling — Claude Code, Cursor | Local Node.js process, .mcp.json |
HTTP + SSE |
Customer-facing, production | apps/mcp-gateway on Cloudflare Workers, mcp.vlozi.app |
5.1 Dev Transport (stdio)
The stdio entrypoint is a plain Node.js script inside apps/mcp-gateway. It runs locally against your dev services — no Cloudflare deployment needed.
Add to root .mcp.json:
{
"mcpServers": {
"code-review-graph": {
"command": "code-review-graph",
"args": ["serve"],
"type": "stdio"
},
"vlozi": {
"command": "node",
"args": ["./apps/mcp-gateway/dist/transports/stdio.js"],
"type": "stdio",
"env": {
"MCP_API_KEY": "${VLOZI_DEV_API_KEY}",
"MCP_TENANT_ID": "${VLOZI_DEV_TENANT_ID}"
}
}
}
}Just turbo dev and the MCP server is live. The stdio script connects directly to locally running product services.
5.2 Production Transport (HTTP/SSE)
Endpoint: https://mcp.vlozi.app/sse
This runs inside apps/mcp-gateway on Cloudflare Workers. apps/gateway is not involved. The SSE transport:
- Client opens a long-lived SSE connection with
Authorization: Bearer ls_xxx mcp-gatewayauthenticates and resolves tenantmcp-gatewaystreams back the tool list filtered to the tenant's plan- Client sends tool calls as POST to
https://mcp.vlozi.app/message mcp-gatewayproxies to the appropriate product Worker via service binding and streams the result back
Standard MCP SSE transport spec — any MCP-compatible client works out of the box.
6. Authentication & Tenant Resolution
Client sends: Authorization: Bearer ls_xxx
OR
x-api-key: ls_xxx
mcp-gateway (using packages/mcp-core auth):
1. Validates API key — KV cache first (5min TTL), DB only on miss
2. Resolves tenantId from key metadata
3. Loads key's permission scopes
4. Builds McpContext { tenantId, permissions, plan }
Per tool call:
5. Checks tool.requiredPermission ∈ context.permissions
6. Checks tool.requiredPlan satisfied by context.plan
7. If either fails → MCP error: "permission_denied"
8. Injects x-tenant-id + x-gateway-key into internal service call
9. Logs: { tenantId, toolName, agentId, durationMs, status }API keys for MCP are the same ls_ keys used for REST API access — no new auth system. packages/mcp-core/src/auth.ts contains the shared validation logic; apps/gateway and apps/mcp-gateway both import it, so there is no duplication.
Permission Mapping (all products)
| Tool | Required Permission |
|---|---|
blog.list_posts |
blog:posts.read |
blog.create_draft |
blog:posts.write |
blog.publish_post |
blog:posts.publish |
blog.get_analytics |
blog:analytics.read |
brain.query |
brain:memory.read |
brain.ingest |
brain:memory.write |
brain.list_memories |
brain:memory.read |
contacts.search |
contacts:read |
contacts.get_memory |
contacts:memory.read |
contacts.enrich |
contacts:write |
contacts.log_interaction |
contacts:interactions.write |
newsletter.list_campaigns |
newsletter:read |
newsletter.get_analytics |
newsletter:analytics.read |
newsletter.create_campaign |
newsletter:write |
content.* |
See docs/content-engine/mcp-server-spec.md |
media.list_assets |
media:read |
media.upload |
media:write |
chat.list_sessions |
chat:sessions.read |
7. Full Tool Catalog
7.1 Blog (blog.*)
| Tool | Description |
|---|---|
blog.list_posts |
List posts with status/tag/date filters |
blog.get_post |
Get full post content and metadata by slug or ID |
blog.create_draft |
Create a new draft |
blog.update_post |
Update title, content, tags, slug of a draft |
blog.publish_post |
Publish a draft immediately or schedule it |
blog.unpublish_post |
Revert a published post to draft |
blog.get_analytics |
Views, reads, scroll depth for a post or date range |
blog.list_tags |
List all tags in the workspace |
blog.search_posts |
Full-text search across posts |
7.2 AI Brain (brain.*)
| Tool | Description |
|---|---|
brain.query |
Semantic search across workspace memory |
brain.ingest |
Add a document or fact to workspace memory |
brain.list_memories |
List recent memories with optional category filter |
brain.delete_memory |
Remove a specific memory by ID |
brain.get_context |
Get assembled context for a given topic (used by agents before composing) |
7.3 Contact Intelligence (contacts.*)
| Tool | Description |
|---|---|
contacts.search |
Search contacts by name, email, company, tag |
contacts.get_contact |
Get full contact profile by ID |
contacts.get_memory |
Get AI memory for a specific contact |
contacts.enrich |
Trigger enrichment for a contact |
contacts.log_interaction |
Record an interaction (email sent, call, meeting) |
contacts.get_outreach_context |
Get assembled context for drafting outreach |
contacts.list_tags |
List all contact tags |
7.4 Newsletter (newsletter.*)
| Tool | Description |
|---|---|
newsletter.list_campaigns |
List campaigns with status filter |
newsletter.get_campaign |
Get campaign details and content |
newsletter.create_campaign |
Create a new campaign draft |
newsletter.get_analytics |
Open rate, click rate, unsub rate for a campaign |
newsletter.list_subscribers |
List subscribers with segment filter |
newsletter.get_subscriber_count |
Count subscribers by segment |
7.5 Content Engine (content.*)
Full tool spec is in docs/content-engine/mcp-server-spec.md. Summary:
| Tool | Description |
|---|---|
content.schedule_content |
Schedule a social post |
content.get_calendar |
Get content calendar for a date range |
content.bulk_schedule |
Schedule multiple posts at once |
content.list_connections |
List connected social accounts |
content.get_publish_status |
Check publish result for a slot |
7.6 Media (media.*)
| Tool | Description |
|---|---|
media.list_assets |
List media assets with type/tag filter |
media.get_asset |
Get asset URL and metadata by ID |
media.search_assets |
Search assets by name or tag |
media.upload |
Upload a new asset (returns upload URL) |
media.delete_asset |
Delete an asset by ID |
7.7 Chat Engine (chat.*)
Read-only tools for agents to query conversation history and session state.
| Tool | Description |
|---|---|
chat.list_sessions |
List chatbot sessions with date/status filter |
chat.get_session |
Get full session transcript by ID |
chat.get_session_stats |
Message count, avg response time, handoff rate |
8. MCP Resources
Resources are read-only context that agents can access without calling a tool. Think of them as live dashboards an agent checks before acting.
| Resource URI | Description |
|---|---|
vlozi://workspace |
Workspace name, plan, active products |
vlozi://blog/recent |
Last 5 published posts |
vlozi://blog/drafts |
All current drafts |
vlozi://contacts/recent |
Last 10 updated contacts |
vlozi://newsletter/stats |
Current subscriber count + last campaign stats |
vlozi://content/calendar/week |
This week's content calendar |
vlozi://brain/recent |
Last 10 memory entries |
vlozi://media/recent |
Last 10 uploaded assets |
9. How to Add a New MCP Tool (Step-by-Step for Devs)
This is the repeatable process every developer follows when adding a tool.
Step 1 — Define the descriptor
In your service's src/mcp/index.ts, add an entry to the tools array:
{
name: 'my_tool',
description: 'One sentence — what this does and when an agent should call it.',
inputSchema: {
type: 'object',
required: ['requiredField'],
properties: {
requiredField: { type: 'string', description: 'What this field is' },
optionalField: { type: 'number', description: 'What this field is' },
},
},
internalRoute: 'POST /mcp/tools/my-tool',
requiredPermission: 'product:resource.action',
}Step 2 — Implement the internal route handler
In your service, add a Hono route that:
- Verifies
x-gateway-keyheader - Reads
x-tenant-idheader (never from body) - Executes the business logic
- Returns
{ data: ..., error: null }on success or{ data: null, error: { code, message } }on failure
// apps/blog-service/src/routes/mcp-tools.ts
app.post('/mcp/tools/my-tool', verifyGatewayKey, async (c) => {
const tenantId = c.req.header('x-tenant-id')!
const body = await c.req.json()
// ... business logic ...
return c.json({ data: result, error: null })
})Step 3 — Register in the mcp-gateway router
In apps/mcp-gateway/src/router.ts, the service registry auto-discovers tools via the descriptor import. If you added the descriptor in Step 1, mcp-gateway picks it up automatically at next deployment.
Step 4 — Add the permission to your permission matrix
In packages/core-access, add the new permission string to the enum and set which default roles receive it.
Step 5 — Test with the stdio dev server
# Start your service locally
turbo dev --filter=blog-service
# In another terminal — run the mcp-gateway stdio server
node apps/mcp-gateway/dist/transports/stdio.js
# Claude Code or Cursor now has the tool availableStep 6 — Write a tool test
// apps/blog-service/src/mcp/tools/__tests__/my-tool.test.ts
it('returns posts for valid tenant', async () => {
const result = await callMcpTool('blog.my_tool', {
requiredField: 'value',
}, { tenantId: TEST_TENANT_ID })
expect(result.error).toBeNull()
expect(result.data).toBeDefined()
})10. Rate Limits & Safety Guards
These are non-negotiable and enforced in apps/mcp-gateway/src/auth.ts.
| Guard | Value | Reason |
|---|---|---|
| Max tool calls / minute / tenant | 120 | Prevent runaway agent loops |
| Max tool calls / minute / API key | 60 | Per-key throttle |
| Write tools require explicit permission | Mandatory | Read-only agents cannot mutate |
All tool calls logged with agentId |
Mandatory | Audit trail, billing |
dryRun: true available on all write tools |
Optional | Agents can preview before acting |
| Max items returned per list tool | 100 | Prevent memory bloat in agent context |
| Bulk operations capped per call | 50 | Prevent calendar/DB flooding |
The agentId header is optional for human-initiated calls but required for agent-initiated calls. Agents must self-identify. The gateway rejects bulk write operations from keys without agentId.
11. Implementation Phases
Phase 1 — Dev Tooling Foundation (Target: Week 1)
Goal: Claude Code and Cursor can call Vlozi tools during development. Zero customer impact.
Deliverables:
-
packages/mcp-core— shared types:McpToolDescriptor,McpContext,McpError, shared auth util -
apps/mcp-gatewayscaffold —wrangler.toml,package.json,tsconfig.json -
apps/mcp-gateway/src/server.ts— MCP server skeleton -
apps/mcp-gateway/src/auth.ts— API key → McpContext resolution (uses mcp-core) -
apps/mcp-gateway/src/router.ts— namespace routing skeleton -
apps/mcp-gateway/src/transports/stdio.ts— stdio transport for local dev - Blog service MCP descriptors + internal route handlers (all 9 tools)
- Brain service MCP descriptors + internal route handlers (all 5 tools)
- Updated
.mcp.jsonwithvlozistdio server entry - Dev setup guide in this file (Section 12)
Done when: A developer can open Claude Code and ask "list my blog drafts" and get a real response.
Phase 2 — Content Engine + Contact Intelligence MCP (Target: Week 2)
Goal: The AI Brain can autonomously operate the content calendar and access contact context.
Deliverables:
- Migrate existing
docs/content-engine/mcp-server-spec.mdtools intocontent-engine/src/mcp/ - Contact Intelligence MCP descriptors + internal route handlers (all 7 tools)
- Newsletter MCP descriptors + internal route handlers (all 6 tools)
- Media MCP descriptors (all 5 tools)
-
apps/mcp-gatewayrouter updated for all new namespaces - Tool permission matrix complete in
packages/core-access
Done when: An agent can query a contact's memory, check the content calendar, and schedule a post — in one conversation.
Phase 3 — HTTP/SSE Production Transport (Target: With Blog v1.0)
Goal: Customers can connect their AI assistant to their Vlozi workspace via mcp.vlozi.app.
Deliverables:
-
apps/mcp-gateway/src/transports/http-sse.ts— Cloudflare Workers SSE transport -
mcp.vlozi.appsubdomain configured and routed toapps/mcp-gateway(notapps/gateway) - CORS + auth for external connections
- Per-plan tool filtering (blog-only plan sees only
blog.*tools) - MCP connection UI in seller dashboard (connect/disconnect, see tool call log)
- Public documentation at
docs.vlozi.app/mcp - Rate limit enforcement for production traffic
Done when: A customer can paste mcp.vlozi.app into Claude Desktop and query their blog.
Phase 4 — Agent Personas & Automation (Post-launch)
Goal: First-party agent personas that customers can enable from the dashboard.
These are pre-built agents that use the MCP tools under the hood:
| Persona | What it does |
|---|---|
| Content Strategist | Checks content calendar gaps, drafts social posts from blog content, schedules automatically |
| Blog Promoter | Triggered on post publish — generates platform-specific copies, bulk schedules across connections |
| Outreach Drafter | Given a contact ID, pulls memory + recent interactions, drafts a personalized outreach email |
| Newsletter Curator | Analyzes recent blog posts and brain memories, drafts a newsletter campaign |
Each persona is a Claude agent with a system prompt and a restricted set of MCP tools. Customers toggle them on/off per workspace.
12. Local Dev Setup
Prerequisites
- Turbo dev stack running (
turbo dev) - A dev API key with all scopes (generate in seller dashboard or seed script)
- Dev tenant ID
Setup
# 1. Build the mcp-gateway stdio entrypoint
turbo build --filter=mcp-gateway
# 2. Set env vars (never commit these)
export VLOZI_DEV_API_KEY=ls_dev_xxxx
export VLOZI_DEV_TENANT_ID=tenant_xxxx
# 3. The .mcp.json is already configured — restart Claude Code / Cursor
# Tools will appear in the tool picker immediatelyVerifying tools are loaded
In Claude Code, type: what vlozi tools do you have access to? — Claude will list all available tools from the MCP server.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No tools appear | stdio server not built | Run turbo build --filter=mcp-gateway |
permission_denied on all tools |
API key lacks scopes | Regenerate key with all scopes |
| Tool call returns 500 | Service not running locally | Run turbo dev --filter=blog-service |
| Stale tool list | MCP server still running old build | Restart Claude Code |
13. Key Decisions Log
| Decision | Rationale |
|---|---|
Dedicated apps/mcp-gateway, not MCP inside apps/gateway |
MCP SDK (~400KB) must not bloat the REST gateway cold start. MCP traffic (agent-driven, SSE, bursty) and REST traffic (human, short-lived) have different profiles and must scale independently. A bad MCP deploy cannot take down the dashboard. |
apps/mcp-gateway built from the start (Phase 1), not later |
Starting with the correct structure costs nothing extra. Avoids a painful migration from gateway-embedded MCP to a dedicated Worker when Phase 3 arrives. |
Auth shared via packages/mcp-core, not duplicated |
apps/gateway and apps/mcp-gateway use the same API key validation logic. One change propagates to both. |
| Tools co-located with services, not in a standalone package | Prevents descriptor drift from implementation. Tool and handler change together. |
| Descriptor-based routing (not live function import) | Respects Rule 7.2 (No Cross-App Imports). mcp-gateway proxies over service bindings; it does not import service logic. |
| Same API keys for REST and MCP | One key management UI. Existing PBAC permission system reused unchanged. |
| stdio for dev, SSE for production | stdio needs zero infra. SSE is the MCP standard for deployed servers. |
agentId required for bulk writes |
Agents must be identifiable for audit and billing. Anonymous bulk mutations are too risky. |