What is MCP
The Model Context Protocol (MCP) is a standard for exposing tools and data to AI models. TWDAgentsHub implements MCP to let AI clients (Claude, GPT, etc.) query ERP data and perform operations through a standardized JSON-RPC interface.
Key Features
- ✓ Stateless HTTP Transport - Fresh server per request, no session management
- ✓ JWT Pass-through Auth - Caller's ERP JWT forwarded to backend
- ✓ Role-based Scoping - Reps see only their data; managers see all
- ✓ Domain Adapters - Pluggable ERP backends (Twendee, etc.)
Architecture
Component Overview
flowchart TB
subgraph Callers["Callers"]
AI["Claude / GPT\n(MCP Client)"]
DA["DynamicAgent\nToolExecutor"]
Test["curl / tests"]
end
subgraph Hub["TWDAgentsHub API"]
MCP["McpController\n/api/mcp/stream"]
Registry["McpToolRegistry"]
Tools["crmToolDefinitions[]"]
end
subgraph Adapter["ERP Adapter Layer"]
TW["TwendeeERPAdapter"]
end
ERP[("Twendee ERP\nAPI")]
AI -->|"POST JSON-RPC\n+ Bearer JWT"| MCP
DA -->|"internal call"| MCP
Test -->|"POST JSON-RPC"| MCP
MCP --> Registry
Registry --> Tools
Tools -->|"handler(input, adapter, ctx)"| TW
TW -->|"HTTP + JWT"| ERP
ERP -->|"JSON response"| TW
Request Flow
sequenceDiagram
participant Client as AI Client / Agent
participant Ctrl as McpController
participant Ctx as extractMcpContext
participant Prov as McpAdapterProvider
participant Reg as McpToolRegistry
participant Tool as crmToolDefinition
participant Adapter as TwendeeERPAdapter
participant ERP as Twendee ERP
Client->>Ctrl: POST /api/mcp/stream
Authorization: Bearer jwt
Ctrl->>Ctx: extractMcpContext(req)
Ctx-->>Ctrl: { userId, roles, requestId }
Ctrl->>Prov: forErpJwt(jwt)
Prov-->>Ctrl: adapter instance
Ctrl->>Reg: registerTools(server, adapter, ctx)
Reg->>Tool: server.registerTool(name, schema, handler)
Note over Ctrl: McpServer.connect(transport)
Client->>Ctrl: tools/call: crm_search_deals
Ctrl->>Tool: handler(input, adapter, ctx)
Tool->>Adapter: adapter.deals.findAll(query, ctx)
Adapter->>ERP: GET /crm/deals + JWT
ERP-->>Adapter: { data: [...] }
Adapter-->>Tool: canonical result
Tool-->>Ctrl: McpToolResult
Ctrl-->>Client: JSON-RPC response
Data Flow
- Client sends JSON-RPC + JWT header
- Controller extracts context from JWT claims
- AdapterProvider binds JWT to adapter
- Registry registers tools on McpServer
- Tool handler calls adapter methods
- Adapter forwards JWT to ERP API
Key Files
- mcp.controller.ts
- mcp-adapter.provider.ts
- mcp-tool-registry.service.ts
- crm-tool-definitions.ts
- extract-mcp-context.ts
Key Components
McpContext
interface McpContext {
userId: string; // From JWT sub/userId claim
roles: string[]; // From JWT role/roles claim
requestId: string; // Auto-generated or x-mcp-request-id header
traceId?: string; // x-mcp-trace-id header (observability)
tenantId?: string; // x-mcp-tenant-id header (multi-tenant)
}
Authentication
Auth Model: Caller sends ERP JWT directly. Hub parses claims for metadata but does NOT verify signature — ERP validates authenticity on each adapter call.
// extract-mcp-context.ts
export function extractMcpContext(req: Request): McpContext {
const authHeader = req.header('authorization') ?? '';
const bearer = authHeader.slice(7).trim();
// Parse JWT claims WITHOUT verification
const claims = parseJwtClaimsUnsafe(bearer);
return {
userId: claims?.userId ?? claims?.sub ?? 'anonymous',
roles: normalizeRoles(claims),
requestId: req.header('x-mcp-request-id') ?? randomUUID(),
};
}
ERP Adapter
interface IERPAdapter {
readonly name: string;
readonly crm?: ICRMAdapter; // CRM domain adapter
authenticate(): Promise<void>;
healthCheck(ctx?: McpContext): Promise<HealthStatus>;
getSupportedDomains(): DomainName[];
}
interface ICRMAdapter {
companies: { findAll, findById };
leads: { findAll, findById };
deals: { findAll, findById, create, update };
followUps: { findAll };
dashboard: { getMetrics };
}
Project Setup
Get started building your own MCP server.
1. Install Dependencies
pnpm add @modelcontextprotocol/sdk zod
2. Environment Variables
# .env
TWENDEE_ERP_BASE_URL=https://staging-erp.twendeesoft.com/api
TWENDEE_ERP_TIMEOUT_MS=15000
3. Register MCP Module
// app.module.ts
import { McpModule } from './mcp/mcp.module';
@Module({
imports: [
ConfigModule.forRoot(),
McpModule,
],
})
export class AppModule {}
Define a Tool
Tools are defined with a Zod schema for input validation and metadata for the AI client.
import { z } from 'zod';
// Simple tool definition
const helloWorldTool = {
name: 'hello_world',
description: 'Returns a greeting for the given name',
inputSchema: {
name: z.string().describe('Name to greet'),
},
// Metadata for tool registry
tier: 'essential', // 'essential' or 'advanced'
destructive: false, // true if modifies data
roleScoped: false, // true if needs ownership filter
};
Tool Metadata
name- Unique identifier (snake_case)description- What the AI sees when selecting toolsinputSchema- Zod schema for parameterstier- Loading priority (essential = always loaded)
Implement Handler
The handler receives validated input and returns an McpToolResult.
// Helper to format JSON results
function jsonResult(value: unknown): McpToolResult {
return {
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }]
};
}
// Tool handler
const helloWorldTool = {
// ... schema from above
handler: async (input, adapter, ctx) => {
// input is already validated by Zod
const greeting = `Hello, ${input.name}!`;
// Return as MCP result
return jsonResult({ greeting, userId: ctx.userId });
},
};
McpToolResult Shape
interface McpToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean; // Set true to indicate failure
}
Register Tools
Tools are registered with the MCP server via the tool registry service.
// mcp-tool-registry.service.ts
@Injectable()
export class McpToolRegistry {
registerTools(server: McpServer, adapter: IERPAdapter, ctx: McpContext) {
for (const tool of crmToolDefinitions) {
server.tool(
tool.name,
tool.description,
zodToJsonSchema(tool.inputSchema),
async (args) => tool.handler(args, adapter.crm!, ctx)
);
}
}
}
Test with curl
Test your MCP server using curl before connecting an AI client.
1. Discovery
curl -s http://localhost:3000/.well-known/mcp.json | jq
2. Initialize Handshake
curl -X POST http://localhost:3000/api/mcp/stream \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": { "name": "curl", "version": "0" }
}
}'
3. List Tools
curl -X POST http://localhost:3000/api/mcp/stream \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
4. Call a Tool
curl -X POST http://localhost:3000/api/mcp/stream \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 3,
"method": "tools/call",
"params": {
"name": "hello_world",
"arguments": { "name": "Developer" }
}
}'
Connect to LLM
Configure Claude Desktop or other MCP clients to use your server.
Claude Desktop Config
// ~/Library/Application Support/Claude/claude_desktop_config.json
{
"mcpServers": {
"twd-agents-hub": {
"url": "http://localhost:3000/api/mcp/stream",
"transport": "streamable-http",
"headers": {
"Authorization": "Bearer YOUR_ERP_JWT"
}
}
}
}
You're Ready!
Restart Claude Desktop, and your tools will appear in the toolbox. Ask Claude to "search for companies" or "list my deals" to test.
Tool Structure
Complete tool definition interface with all available options.
interface CrmToolDefinition<TInput extends z.ZodRawShape> {
// Identity
name: string; // e.g., "crm_search_deals"
title: string; // Human-readable name
description: string; // What the tool does (shown to AI)
// Input validation
inputSchema: TInput; // Zod schema for parameters
// Metadata
tier: 'essential' | 'advanced'; // Loading priority
destructive: boolean; // Requires confirmation?
roleScoped: boolean; // Needs ownership filter?
// Implementation
handler: (
input: z.infer<z.ZodObject<TInput>>,
adapter: ICRMAdapter,
ctx: McpContext,
) => Promise<McpToolResult>;
}
Example: Search Deals Tool
export const crmSearchDealsTool: CrmToolDefinition = {
name: 'crm_search_deals',
title: 'Search CRM deals',
description: `Search CRM deals (sales opportunities). Returns paginated list
with name, status, type, value, currency. Rep users only see deals
they created OR follow; manager-tier users see all.`,
inputSchema: {
page: z.number().int().positive().optional(),
limit: z.number().int().positive().max(100).optional(),
search: z.string().optional(),
status: z.string().optional(),
companyId: z.string().optional(),
},
tier: 'essential',
destructive: false,
roleScoped: true,
handler: async (input, adapter, ctx) => {
const query = scopeDealQuery(input, ctx);
const result = await adapter.deals.findAll(query, ctx);
return jsonResult(result);
},
};
Role Scoping
Tools marked with roleScoped: true filter data based on user role.
const MANAGER_ROLES = ['ADMIN', 'MANAGER', 'SALES_MANAGER', 'CRM_ADMIN'];
export function isManagerRole(ctx: McpContext): boolean {
return ctx.roles.some(r => MANAGER_ROLES.includes(r.toUpperCase()));
}
export function scopeDealQuery(query: DealQuery, ctx: McpContext): DealQuery {
if (isManagerRole(ctx)) return query;
// Rep users: inject followerId filter
return { ...query, followerId: ctx.userId };
}
Manager Role
- ✓ See all deals
- ✓ See all follow-ups
- ✓ Team-wide dashboard
Rep Role
- ✓ Own deals only
- ✓ Own follow-ups only
- ✓ Personal dashboard
Write Tools
Tools that modify data require additional safety wrappers and return undo tokens.
// Write tool response shape
interface WriteToolResponse<T> {
result: T; // Created/updated record
undoToken: string; // Base64-encoded undo payload
undoExpiresAt: number; // TTL (5 minutes)
auditId: string; // For logging
}
// Usage
const response = await crm_create_deal({ name: "Big Deal", ... });
// Returns: { result: {...}, undoToken: "eyJ...", undoExpiresAt: 1775893391058 }
// To undo within 5 minutes:
await crm_undo({ undoToken: response.undoToken });
// Returns: { undone: true, toolName: "crm_create_deal", ... }
Stage Transition Guards
// Legal transitions (illegal moves rejected unless overrideReason provided)
DISCOVERY → PREPARING_PROPOSAL, ON_HOLD, DROPPED, LOST
PREPARING_PROPOSAL → PROPOSAL_SENT, DISCOVERY, ON_HOLD, DROPPED, LOST
PROPOSAL_SENT → WON, LOST, ON_HOLD, DROPPED
WON → PROJECT_STARTED
PROJECT_STARTED → PROJECT_COMPLETED, ON_HOLD
PROJECT_COMPLETED → (terminal)
LOST → (terminal)
DROPPED → (terminal)
ON_HOLD → DISCOVERY
Destructive Operations
Tools with destructive: true get audit logging,
validation guards, and 5-minute undo capability.
Error Handling
Tools return errors via the isError flag with structured prefixes.
// Success response
{
"content": [{ "type": "text", "text": "{...JSON data...}" }]
}
// Error response
{
"content": [{ "type": "text", "text": "Error: [PREFIX] message" }],
"isError": true
}
Error Prefixes
| Prefix | Meaning |
|---|---|
| [ROLE_SCOPING_DENIED] | User lacks permission to access resource |
| [ILLEGAL_STAGE_TRANSITION] | Deal status change not allowed |
| [UNDO_ALREADY_CONSUMED] | Undo token already used or expired |
| [UNDO_OWNERSHIP_MISMATCH] | User didn't create the original action |
| [UNDO_TOKEN_INVALID] | Token tampered or malformed |
Adapter Pattern
Adapters abstract ERP backends, allowing tools to work with any data source.
// McpAdapterProvider - creates adapter per request
@Injectable()
export class McpAdapterProvider {
constructor(private config: ConfigService) {}
forErpJwt(erpJwt: string): IERPAdapter {
return new TwendeeERPAdapter({
baseUrl: this.config.get('TWENDEE_ERP_BASE_URL'),
jwtProvider: () => erpJwt, // Injected per-request
timeoutMs: 15000,
});
}
}
Adding a New Adapter
- Implement
IERPAdapterinterface - Add domain adapters (CRM, HR, etc.) as needed
- Register in
McpAdapterProvider - Tools automatically work via interface contract
Manual Testing
Test MCP endpoints with curl against a running dev server.
1. Start Dev Server
cd /path/to/TWDAgentsHub
TWENDEE_ERP_BASE_URL='https://staging-erp.twendeesoft.com/api' \
pnpm --filter @twd/api exec tsx src/mcp/scripts/start-mcp-dev.ts
# Listens on http://127.0.0.1:3999
2. Export Your JWT
export TWENDEE_TOKEN='<your twendee-erp JWT>'
3. Test Commands
# Discovery
curl -s http://127.0.0.1:3999/.well-known/mcp.json | jq
# Initialize
curl -s -X POST http://127.0.0.1:3999/api/mcp/stream \
-H "authorization: Bearer $TWENDEE_TOKEN" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"0"}}}'
# List tools
curl -s -X POST http://127.0.0.1:3999/api/mcp/stream \
-H "authorization: Bearer $TWENDEE_TOKEN" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list"}'
# Search deals
curl -s -X POST http://127.0.0.1:3999/api/mcp/stream \
-H "authorization: Bearer $TWENDEE_TOKEN" \
-H "content-type: application/json" \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"crm_search_deals","arguments":{"limit":5}}}'
Response Format
The SDK streams JSON-RPC as Server-Sent Events. To parse:
curl ... | grep '^data:' | sed 's/^data: //' | jq
MCP Inspector
Use the official MCP Inspector GUI for interactive testing.
Launch Inspector
npx @modelcontextprotocol/inspector
Connection Settings
- Transport type:
Streamable HTTP - URL:
http://127.0.0.1:3999/api/mcp/stream - Headers:
authorization: Bearer <TWENDEE_TOKEN>
Click Connect → Browse tools → Call via GUI
Automated Tests
Run the smoke test script against staging to verify all tools work.
TWENDEE_TOKEN=<your_jwt> \
pnpm --filter @twd/api exec tsx src/mcp/scripts/test-mcp-live.ts
What It Tests
- Discovery endpoint
- MCP initialize handshake
- tools/list (expects 16+ tools)
- Read tools (search_companies, search_deals, etc.)
- Write + undo cycle (create company → undo → verify deleted)
- Undo one-shot enforcement (2nd call rejected)
- Illegal stage transition guard
Exits 0 on all pass, 1 on any failure.