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

  1. Client sends JSON-RPC + JWT header
  2. Controller extracts context from JWT claims
  3. AdapterProvider binds JWT to adapter
  4. Registry registers tools on McpServer
  5. Tool handler calls adapter methods
  6. 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

read

Search CRM companies with pagination and filters

crm_get_company

read

Get single company by ID

crm_search_leads

read

Search CRM leads (prospective contacts)

crm_get_lead

read

Get single lead by ID

crm_search_deals

scoped read

Search deals (role-scoped: reps see own deals only)

crm_get_deal

scoped read

Get single deal by ID (ownership enforced)

crm_list_follow_ups

scoped read

List scheduled follow-ups (calls, meetings)

crm_get_dashboard_metrics

scoped read

Pipeline KPIs: total deals, value, won/lost counts