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

  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

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 tools
  • inputSchema - Zod schema for parameters
  • tier - 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

  1. Implement IERPAdapter interface
  2. Add domain adapters (CRM, HR, etc.) as needed
  3. Register in McpAdapterProvider
  4. 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

  1. Discovery endpoint
  2. MCP initialize handshake
  3. tools/list (expects 16+ tools)
  4. Read tools (search_companies, search_deals, etc.)
  5. Write + undo cycle (create company → undo → verify deleted)
  6. Undo one-shot enforcement (2nd call rejected)
  7. Illegal stage transition guard

Exits 0 on all pass, 1 on any failure.