MCP as an Enterprise Integration Primitive

Why Model Context Protocol is the missing layer between AI agents and enterprise systems — and how to build adapters for ERPs, SCADA historians, and legacy databases that hold up in production.

The enterprise integration problem

Here's the integration problem nobody talks about in AI demos: the interesting data is never in a REST API with clean JSON and a Swagger doc. It's in a 25-year-old ERP with a proprietary JDBC driver. It's in a SCADA historian that speaks OPC-UA. It's in a SQL Server database with 400 tables and no documentation. It's in a CSV export that someone runs manually every morning.

The standard AI tutorial has you calling fetch() on a nice external API and pretending that's integration. Real enterprise AI means connecting agents to the systems that actually run the business — and those systems were built before anyone was thinking about LLMs.

For most of my career I've done this with MuleSoft: design the API contract, build the transformation layer, handle the error cases, manage the retry logic. It works, and it scales. But for AI agent workloads, there's a newer primitive that fits better: Model Context Protocol (MCP).

What MCP actually is

MCP is an open protocol, developed by Anthropic, that standardizes how AI models connect to external tools and data sources. Think of it as USB-C for AI integrations: one standard plug, many devices.

The protocol defines three primitives:

The transport layer is JSON-RPC over stdio or HTTP/SSE. Your MCP server exposes a manifest of available tools; the AI host (Claude, GPT-4, whatever) reads the manifest and knows what it can call. When the agent decides to call a tool, it sends a structured request; your server executes the logic and returns a structured response.

That's it. No custom API spec negotiation, no prompt engineering to describe every function, no hoping the LLM will figure out how to call your bespoke REST endpoints.

Why MCP beats the alternatives

You have a few options for connecting an AI agent to enterprise systems. Here's how they compare honestly:

ApproachProsCons
Direct database access Simple, no intermediary Agent writes raw SQL. Production databases. What could go wrong.
Custom REST API Full control, familiar You write the spec, describe it in every prompt, maintain it separately
Function calling (ad-hoc) Supported by most LLM APIs Not portable across providers, schema defined per-call
MCP server Standard protocol, portable, tool discovery, composable Newer ecosystem, more setup than a simple function call

The killer feature of MCP is portability and composability. An MCP server you write for Claude works with any MCP-compatible host. You can compose multiple servers — one for your ERP, one for your SCADA historian, one for your document store — and the agent sees them as a unified tool surface. You don't rewrite the integration every time you switch models or add a new agent.

For enterprise integration specifically, MCP also enforces a clean boundary: the agent calls named functions with typed inputs. It never touches the underlying system directly. That boundary is where you put your validation, your authorization checks, your audit logging, and your human-in-loop gates.

Building an MCP server in TypeScript

The MCP TypeScript SDK makes the server plumbing straightforward. You declare your tools, implement the handlers, and expose the server. Here's the skeleton:

src/index.ts — MCP server setup
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";

const server = new McpServer({
  name: "enterprise-quoting",
  version: "1.0.0",
});

// Register a tool — name, description, input schema, handler
server.tool(
  "ingest_rfp",
  "Parse an RFP text and extract structured requirements: material, processes, quantity, tolerances, finish, and contact info.",
  {
    raw_text: z.string().describe("The raw RFP text to parse"),
    customer_name: z.string().describe("Customer name"),
    contact_email: z.string().email().describe("Contact email address"),
  },
  async ({ raw_text, customer_name, contact_email }) => {
    const parsed = await parseRfp(raw_text);
    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          ...parsed,
          customer_name,
          contact_email,
          parsed_at: new Date().toISOString(),
        }),
      }],
    };
  }
);

// Wire up transport and start
const transport = new StdioServerTransport();
await server.connect(transport);

The Zod schema on each tool does double duty: it validates the inputs at runtime and generates the JSON Schema that the AI host uses to understand what parameters to pass. You write it once; the protocol handles the rest.

For HTTP transport instead of stdio (useful when your MCP server needs to run as a standalone service rather than a subprocess):

src/index.ts — HTTP/SSE transport
import express from "express";
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";

const app = express();
app.use(express.json());

// SSE endpoint — the AI host connects here for streaming
app.get("/sse", async (req, res) => {
  const transport = new SSEServerTransport("/messages", res);
  await server.connect(transport);
});

// POST endpoint — the AI host sends tool calls here
app.post("/messages", async (req, res) => {
  // transport handles routing to the right tool handler
  await transport.handlePostMessage(req, res);
});

app.listen(3789, () => console.log("MCP server running on :3789"));

Real example: the quoting system

The MCP quoting system I built handles a specific workflow: a customer sends an RFP, an estimator needs to price it quickly. The old process was manual — dig through past quotes, find similar jobs, do the math by hand. The MCP server automates the lookup and drafts the estimate; the human reviews and approves.

Six tools, one coordinator:

ToolWhat it does
ingest_rfpParse RFP text → structured requirements (material, processes, qty, tolerances, finish)
find_similar_quotesSearch historical database by weighted similarity (material 35%, processes 30%, qty 20%, tolerances 10%, finish 5%)
estimate_cost_lead_timeActivity-based costing: material + processing + labor + tooling + overhead + margin
generate_quoteFormat a draft quote document, status = "draft"
approve_quoteHuman-in-loop gate — marks quote approved, creates audit record
send_quoteEmail delivery (dry-run by default, requires explicit enable)

The similarity search is where I spent the most time. Vector search would be ideal — embed the RFP text, find nearest neighbors. For an MVP with a few hundred historical quotes, a rule-based weighted scorer is faster to ship and easier to explain to the estimators who need to trust it:

src/matcher.ts — similarity scoring
interface NormalizedRfp {
  material: string;
  processes: string[];
  qtyRange: [number, number];
  tolerances: string;
  finish: string;
}

interface HistoricalQuote extends NormalizedRfp {
  id: string;
  costPerUnit: number;
  leadDays: number;
  approved: boolean;
}

const WEIGHTS = {
  material:   0.35,
  processes:  0.30,
  quantity:   0.20,
  tolerances: 0.10,
  finish:     0.05,
};

function scoreSimilarity(rfp: NormalizedRfp, quote: HistoricalQuote): number {
  // Material: exact match = 1.0, same family (aluminum-*) = 0.7, partial = 0.4
  const materialScore = scoreMaterial(rfp.material, quote.material);

  // Processes: Jaccard overlap of required process sets
  const rfpSet = new Set(rfp.processes);
  const quoteSet = new Set(quote.processes);
  const intersection = [...rfpSet].filter(p => quoteSet.has(p)).length;
  const union = new Set([...rfpSet, ...quoteSet]).size;
  const processScore = union > 0 ? intersection / union : 0;

  // Quantity: same range = 1.0, adjacent range = 0.7, 2 ranges away = 0.4
  const qtyScore = scoreQuantityRange(rfp.qtyRange, quote.qtyRange);

  // Tolerances: exact = 1.0, within one tier = 0.6
  const toleranceScore = scoreTolerances(rfp.tolerances, quote.tolerances);

  // Finish: exact = 1.0, none vs some = 0.5
  const finishScore = rfp.finish === quote.finish ? 1.0 :
                      (!rfp.finish || !quote.finish) ? 0.5 : 0.2;

  return (
    materialScore   * WEIGHTS.material   +
    processScore    * WEIGHTS.processes  +
    qtyScore        * WEIGHTS.quantity   +
    toleranceScore  * WEIGHTS.tolerances +
    finishScore     * WEIGHTS.finish
  );
}

function findSimilarQuotes(
  rfp: NormalizedRfp,
  history: HistoricalQuote[],
  topK = 5,
): Array<{ quote: HistoricalQuote; score: number; confidence: string }> {
  return history
    .filter(q => q.approved)  // only use quotes that actually won
    .map(q => ({
      quote: q,
      score: scoreSimilarity(rfp, q),
      confidence: q.score >= 0.85 ? "high" : q.score >= 0.70 ? "medium" : "low",
    }))
    .sort((a, b) => b.score - a.score)
    .slice(0, topK);
}

Confidence thresholds matter here operationally. High confidence (≥85%) means the job is very similar to past work — the estimate goes to the estimator for a quick sanity check. Low confidence (<70%) means this is a new type of work — it gets flagged for full engineer review and the cost estimate automatically adds a 10% contingency.

Future upgrade path: Replace the rule-based scorer with pgvector semantic search on embedded RFP text. The tool interface stays identical — the caller never knows whether similarity is computed by rules or vectors. That's the value of the MCP boundary.

Extending to manufacturing: SCADA and ERP adapters

The quoting system is a clean bounded domain. The harder version of this problem is bridging an AI agent to the systems that run a manufacturing facility — and those systems were not built with interoperability in mind.

Here's the adapter pattern I use. Each external system gets its own MCP tool group, with a thin adapter class that handles the protocol translation:

src/adapters/scada.ts — Ignition historian adapter
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import OPCUAClient from "node-opcua";

export function registerScadaTools(server: McpServer, opcuaEndpoint: string) {

  server.tool(
    "get_tag_history",
    "Retrieve historical values for a SCADA tag from the Ignition historian. Returns timestamped readings over a time window.",
    {
      tag_path: z.string().describe("Ignition tag path, e.g. [default]Facility/Line1/Motor_Speed"),
      start_time: z.string().describe("ISO 8601 start timestamp"),
      end_time: z.string().describe("ISO 8601 end timestamp"),
      max_samples: z.number().int().min(1).max(10000).default(1000),
    },
    async ({ tag_path, start_time, end_time, max_samples }) => {
      const client = new OPCUAClient({ endpoint_must_exist: false });
      await client.connect(opcuaEndpoint);

      const session = await client.createSession();
      const readings = await session.readHistoryValue(
        tag_path,
        new Date(start_time),
        new Date(end_time),
        { numValuesPerNode: max_samples }
      );

      await session.close();
      await client.disconnect();

      return {
        content: [{
          type: "text",
          text: JSON.stringify({
            tag: tag_path,
            readings: readings.map(r => ({
              timestamp: r.sourceTimestamp?.toISOString(),
              value: r.value?.value,
              quality: r.statusCode?.name,
            })),
          }),
        }],
      };
    }
  );

  server.tool(
    "get_current_tag_value",
    "Read the current live value of a SCADA tag.",
    {
      tag_path: z.string().describe("Ignition tag path"),
    },
    async ({ tag_path }) => {
      // ... OPC-UA read implementation
    }
  );
}
src/adapters/erp.ts — mid-tier ERP adapter via JDBC bridge
export function registerErpTools(server: McpServer, jdbcUrl: string) {

  server.tool(
    "get_work_orders",
    "Query open work orders from the ERP system, optionally filtered by status, department, or date range.",
    {
      status: z.enum(["open", "in_progress", "completed", "all"]).default("open"),
      department: z.string().optional().describe("Department code to filter by"),
      due_before: z.string().optional().describe("ISO 8601 date — return orders due before this date"),
      limit: z.number().int().min(1).max(500).default(50),
    },
    async ({ status, department, due_before, limit }) => {
      // JDBC bridge handles the SQL — agent never writes raw queries
      const rows = await jdbcQuery(jdbcUrl, `
        SELECT wo_number, description, department, status, due_date, priority
        FROM work_orders
        WHERE ($1 = 'all' OR status = $1)
          AND ($2::text IS NULL OR department = $2)
          AND ($3::date IS NULL OR due_date < $3::date)
        ORDER BY priority DESC, due_date ASC
        LIMIT $4
      `, [status, department ?? null, due_before ?? null, limit]);

      return {
        content: [{ type: "text", text: JSON.stringify({ work_orders: rows }) }],
      };
    }
  );
}

The agent never writes SQL. It calls get_work_orders with named parameters. The adapter translates to whatever the underlying system needs — SQL, OPC-UA, REST, MQTT, flat file. That translation layer is where you put your access control: a maintenance tech's agent can read work orders, not modify them. A supervisor's agent gets write access. The MCP boundary enforces this at the tool level.

On OPC-UA specifically: OPC-UA is the industrial standard for real-time data access, but its TypeScript ecosystem is rougher than Python's. The node-opcua library is solid but verbose. If you have a choice, expose an OPC-UA → REST bridge (Ignition's web API does this) and call that from your MCP adapter. Simpler code, easier debugging.

Human-in-loop as a first-class pattern

This is the design decision that actually makes enterprise AI safe to deploy, and MCP handles it more cleanly than any alternative I've used.

The pattern: any action with real-world consequences — sending a quote, updating an ERP record, triggering a maintenance work order — goes through a tool that creates a pending approval rather than executing immediately. A separate tool, guarded by auth, performs the actual execution only after a human reviews.

src/tools/approvals.ts
// Step 1: Agent calls this — creates a pending action, returns approval ID
server.tool(
  "draft_work_order",
  "Create a draft maintenance work order for human review. Does NOT submit to ERP until approved.",
  {
    equipment_id: z.string(),
    description: z.string(),
    priority: z.enum(["low", "medium", "high", "critical"]),
    assigned_to: z.string().optional(),
    estimated_hours: z.number().positive(),
  },
  async (params) => {
    const draft = await db.pendingActions.create({
      type: "work_order",
      payload: params,
      status: "awaiting_approval",
      created_at: new Date(),
      expires_at: new Date(Date.now() + 24 * 60 * 60 * 1000),  // 24h TTL
    });

    return {
      content: [{
        type: "text",
        text: JSON.stringify({
          draft_id: draft.id,
          status: "awaiting_approval",
          message: "Work order drafted. A supervisor must approve before submission to ERP.",
          review_url: `https://app.internal/approvals/${draft.id}`,
        }),
      }],
    };
  }
);

// Step 2: Human reviews in the UI, then triggers this (auth-gated)
server.tool(
  "approve_work_order",
  "Approve a drafted work order and submit it to the ERP. Requires supervisor role.",
  {
    draft_id: z.string(),
    approver_id: z.string().describe("Supervisor employee ID"),
    notes: z.string().optional(),
  },
  async ({ draft_id, approver_id, notes }, context) => {
    // Check authorization
    const approver = await getEmployee(approver_id);
    if (!approver.roles.includes("supervisor")) {
      throw new Error("Unauthorized: supervisor role required to approve work orders");
    }

    const draft = await db.pendingActions.findById(draft_id);
    if (!draft || draft.status !== "awaiting_approval") {
      throw new Error("Draft not found or already processed");
    }

    // Now actually submit to ERP
    const woNumber = await erpClient.createWorkOrder(draft.payload);

    await db.pendingActions.update(draft_id, {
      status: "approved",
      approved_by: approver_id,
      approved_at: new Date(),
      erp_work_order: woNumber,
      notes,
    });

    return {
      content: [{
        type: "text",
        text: JSON.stringify({ work_order_number: woNumber, status: "submitted" }),
      }],
    };
  }
);

This pattern gives you a full audit trail for free: every proposed action is a record in your database with who requested it, what the parameters were, who approved it, and when. Regulators and quality teams love this. It also gives you a natural circuit breaker — if the agent starts proposing nonsensical work orders, you see it in the approval queue before anything bad happens.

TTL on pending actions: The 24-hour expiry is intentional. If a drafted work order sits unreviewed for a day, it expires. This prevents stale AI suggestions from being approved weeks later when context has changed. Tune the TTL to match your operational tempo.

What I'd do differently

Start with stdio transport, add HTTP later. stdio is simpler to develop and debug — your MCP server runs as a child process of the AI host, no networking to deal with. Switch to HTTP/SSE when you need the server to run independently (multiple clients, long-running background tasks, separate deployment). Don't over-engineer the transport upfront.

Version your tool schemas from day one. Tool parameter schemas evolve. If you're mid-conversation and you change a tool's schema, sessions started before the change break. Put a version in your server name (enterprise-quoting-v2) and treat schema changes as breaking changes. It's annoying to maintain, but much less annoying than debugging silent breakage in production agent sessions.

Log every tool call with full inputs and outputs. Not a sample — every call. In a production enterprise environment you need this for debugging, for compliance, and for catching the cases where the agent is calling tools in an unexpected sequence. The MCP SDK makes it easy to add middleware at the server level.

Write integration tests against a real agent, not mock calls. It's tempting to unit test each tool handler in isolation and call it done. The failures I've hit in production were always in how the agent orchestrated tools — calling generate_quote before estimate_cost, passing output from one tool directly to another without the right transformation. You need end-to-end tests that run a real LLM through the full workflow.


The MCP quoting system is on GitHub if you want to see a complete implementation. The manufacturing adapters (SCADA, ERP) are part of Harmony Core and aren't public, but the patterns here are complete enough to adapt.

Questions or pushback: email me or find me on LinkedIn.

Next in this series: On-Premises vs. Cloud AI for Manufacturing — why SMB manufacturers can't just call the OpenAI API, what data sovereignty means on the shop floor, and how to architect systems that work with no internet connection.