Overview
TWDAgentsHub implements the Model Context Protocol (MCP) to expose ERP data as AI-callable tools. AI clients (Claude, GPT, etc.) can query CRM data, search deals, 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 Sequence
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
Setup
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 {}
4. Test the Endpoint
# Discovery endpoint
curl http://localhost:3000/.well-known/mcp.json
# Call a tool
curl -X POST http://localhost:3000/api/mcp/stream \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ERP_JWT" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "crm_search_deals",
"arguments": { "limit": 10 }
}
}'
Authentication
Phase 03a Auth Model: Caller sends ERP JWT directly. Hub parses claims for metadata but does NOT verify signature — ERP validates authenticity on each adapter call.
JWT Extraction
// 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(),
traceId: req.header('x-mcp-trace-id'),
tenantId: req.header('x-mcp-tenant-id'),
};
}
Security Note
Identity fields (userId, roles)
come ONLY from JWT claims. Header overrides are NOT supported to prevent privilege escalation.
MCP Context
// McpContext interface
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)
}
The context flows through every tool handler and adapter method, enabling:
- • Role-based filtering — scoping queries to user's data
- • Audit logging — tracking who called what
- • Distributed tracing — correlating requests across services
ERP Adapters
// IERPAdapter interface
interface IERPAdapter {
readonly name: string;
readonly crm?: ICRMAdapter; // CRM domain adapter
// Future: hr?, finance?, recruitment?
authenticate(): Promise<void>;
healthCheck(ctx?: McpContext): Promise<HealthStatus>;
getSupportedDomains(): DomainName[];
}
// ICRMAdapter provides domain-specific methods
interface ICRMAdapter {
companies: { findAll, findById };
leads: { findAll, findById };
deals: { findAll, findById, create, update };
followUps: { findAll };
dashboard: { getMetrics };
}
Creating an Adapter Instance
// McpAdapterProvider
@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,
});
}
}
Tool Structure
Each MCP tool follows a standardized definition structure with Zod schema validation:
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>;
}
interface McpToolResult {
content: Array<{ type: 'text'; text: string }>;
isError?: boolean;
}
Example Tool
Complete example of a read-only CRM tool:
import { z } from 'zod';
import type { ICRMAdapter, McpContext, DealQuery } from '@twd/erp-adapters';
// Helper to format results
function jsonResult(value: unknown): McpToolResult {
return { content: [{ type: 'text', text: JSON.stringify(value, null, 2) }] };
}
// Role-based query scoping
function scopeDealQuery(query: DealQuery, ctx: McpContext): DealQuery {
if (isManagerRole(ctx)) return query;
return { ...query, followerId: ctx.userId };
}
// Tool definition
export const crmSearchDealsTool: CrmToolDefinition<{
page: z.ZodOptional<z.ZodNumber>;
limit: z.ZodOptional<z.ZodNumber>;
search: z.ZodOptional<z.ZodString>;
status: z.ZodOptional<z.ZodString>;
companyId: z.ZodOptional<z.ZodString>;
}> = {
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()
.describe('Page number (1-based)'),
limit: z.number().int().positive().max(100).optional()
.describe('Items per page (max 100)'),
search: z.string().optional()
.describe('Free-text search'),
status: z.string().optional()
.describe('Deal status (DISCOVERY, PROPOSAL_SENT, WON, LOST)'),
companyId: z.string().optional()
.describe('Filter by company ID'),
},
tier: 'essential',
destructive: false,
roleScoped: true,
handler: async (input, adapter, ctx) => {
const query = scopeDealQuery(input as DealQuery, ctx);
const result = await adapter.deals.findAll(query, ctx);
return jsonResult(result);
},
};
Role Scoping
Tools marked with roleScoped: true must filter data based on user role:
// role-scoping.ts
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 class RoleScopingDeniedError extends Error {
constructor(resource: string, id: string, userId: string) {
super(`Access denied: user ${userId} cannot access ${resource} ${id}`);
this.name = 'RoleScopingDeniedError';
}
}
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:
// Write tool with safety wrapper
export const crmCreateDealTool: CrmToolDefinition = {
name: 'crm_create_deal',
title: 'Create CRM deal',
description: 'Create a new sales deal in the CRM.',
inputSchema: {
name: z.string().min(1).describe('Deal name'),
companyId: z.string().describe('Company ID'),
value: z.number().positive().optional(),
currency: z.string().default('USD'),
},
tier: 'advanced',
destructive: true, // Requires confirmation
roleScoped: false,
handler: wrapWithWriteSafety(async (input, adapter, ctx) => {
const result = await adapter.deals.create(input, ctx);
return jsonResult(result);
}),
};
// Safety wrapper adds confirmation + audit logging
function wrapWithWriteSafety(handler) {
return async (input, adapter, ctx) => {
// Log the write attempt
logger.info(`Write operation by ${ctx.userId}`, { input });
// Execute with transaction if available
return handler(input, adapter, ctx);
};
}
Destructive Operations
Tools with destructive: true are wrapped with the write-safety
HOF, which adds audit logging, validation guards, and (in Phase 08b) undo capability.
Endpoints
| Method | Path | Description |
|---|---|---|
| GET | /.well-known/mcp.json | MCP discovery metadata |
| POST | /api/mcp/stream | JSON-RPC tool calls |
Discovery Response
{
"name": "twd-agents-hub",
"version": "0.1.0",
"protocolVersion": "2025-06-18",
"transport": {
"type": "streamable-http",
"url": "https://hub.example.com/api/mcp/stream"
},
"capabilities": {
"tools": { "listChanged": true }
},
"auth": {
"required": true,
"schemes": ["bearer"]
}
}
Tools Catalog
crm_search_companies
readSearch CRM companies with pagination and filters
crm_get_company
readGet single company by ID
crm_search_leads
readSearch CRM leads (prospective contacts)
crm_get_lead
readGet single lead by ID
crm_search_deals
Search deals (role-scoped: reps see own deals only)
crm_get_deal
Get single deal by ID (ownership enforced)
crm_list_follow_ups
List scheduled follow-ups (calls, meetings)
crm_get_dashboard_metrics
Pipeline KPIs: total deals, value, won/lost counts