MCP from Scratch: How to Connect AI Agents to Your Systems

MCP from Scratch: How to Connect AI Agents to Your Systems

A practical guide to understanding what the Model Context Protocol is, when it's worth using, and how to build your first MCP server with TypeScript.

Language models are good at analyzing text, reasoning, and generating responses, but on their own they can't query your company's database, review a ticket, or perform an operation in your system. To do that, they need a secure, structured way to communicate with the outside world.

That's where MCP, or Model Context Protocol, comes in.

MCP is an open standard for connecting AI applications to external data, tools, and workflows. A useful analogy is to think of MCP as a USB-C port for AI agents: instead of building a different integration for every assistant, you expose a standard interface that different compatible clients can understand.

In this article, we'll build a small MCP server to manage support tickets. By the end you'll be able to:

  • explain what problem MCP solves;
  • distinguish between host, client, and server;
  • decide when to use MCP and when not to;
  • understand tools, resources, and prompts;
  • run a local server;
  • test it with MCP Inspector;
  • connect it to Codex;
  • recognize the changes needed before taking it to production.

The problem MCP tries to solve

Imagine a company has:

  • tickets in an internal platform;
  • customers in a CRM;
  • invoices in QuickBooks;
  • documentation in SharePoint;
  • deployments in Azure DevOps.

You want an agent to be able to respond to requests like:

Check ticket 101, tell me its priority, and mark it resolved if it already has a fix.

Without MCP, you'd have to build a custom integration between each agent and each system. You'd also have to figure out on your own how to describe the available operations, validate their arguments, return results, and handle errors.

With MCP, you build a server that publishes capabilities under a common contract. A compatible client can discover them and use them without knowing the internal implementation.

flowchart LR
    U["User"] --> H["AI Host"]
    H --> C["MCP Client"]
    C --> S["MCP Server"]
    S --> D["API, database, or files"]

MCP doesn't replace your application's business logic or security. It's the standardized layer that lets the agent find and use whichever capabilities you decide to publish.

The main pieces

Host

This is the application where the user interacts with the model. Codex, ChatGPT, an IDE, or a custom app can all act as a host.

The host manages the conversation, presents authorization requests, and decides which servers it can use.

MCP Client

This is the component of the host that maintains the connection to an MCP server. It's responsible for negotiating capabilities, discovering tools, and transporting requests and responses.

You typically don't have to build this yourself if you use a host that already supports MCP.

MCP Server

This is the program you expose. It can wrap a REST API, a database, local files, or any business service.

A server mainly publishes three types of capabilities:

Capability What it represents Example
Tool A function the model can request to run Change a ticket's status
Resource Information the client can read as context Support manual or customer profile
Prompt A reusable template to guide a task Analyze a ticket following a process

An easy way to remember it:

  • Tools do things.
  • Resources provide information.
  • Prompts teach how to approach a task.

How does a call happen?

Suppose the user types: "Look up ticket 101."

  1. The client asks the server which tools it offers.
  2. The server describes get_ticket and its parameter schema.
  3. The model determines that this tool can resolve the request.
  4. The host requests approval when needed.
  5. The client sends a call with { "id": 101 }.
  6. The server validates the argument, runs the logic, and returns the result.
  7. The model interprets that result and replies to the user.

The model never goes directly into the database. It can only request execution of the operations the server has published.

What is MCP used for?

MCP is especially useful for:

  • querying private or up-to-date information the model doesn't know;
  • letting an agent call internal APIs;
  • creating or updating tickets, invoices, tasks, or records;
  • querying company documentation;
  • automating workflows that span multiple systems;
  • reusing one integration across different compatible clients;
  • giving an agent tools with well-defined names, descriptions, and arguments.

For example, you could build a QuickBooks MCP server with tools like create_invoice, find_customer, and list_companies. The agent would know which operation to pick from its description and could validate the data before invoking it.

When should I use it?

Use MCP when several of these conditions apply:

  • the functionality will be consumed by one or more AI agents or assistants;
  • you want capabilities to be dynamically discoverable;
  • you need to reuse the same integration across different hosts;
  • you want to decouple the agent from the real API or database;
  • you need clear contracts and structured input validation;
  • you're going to offer a related set of tools, resources, or prompts.

When is it not needed?

MCP isn't mandatory for every AI project. You probably don't need it if:

  • your app only makes a fixed, simple call to an API;
  • no model needs to decide which tool to use;
  • the consumer isn't an agent or an MCP-compatible client;
  • a direct internal function is enough;
  • you're adding MCP only because it's trendy.

A practical rule of thumb: if the integration will only ever be called by your backend in a fully deterministic way, a regular function or API is usually simpler. If you want different agents to discover and use capabilities through a common contract, that's when MCP starts adding value.

MCP is not the same as function calling, an API, or RAG

Technology Main purpose
REST/GraphQL API Expose a system's operations to other programs
Function calling Let a model select functions defined by an application
RAG Retrieve relevant documents to feed into the model's context
MCP Standardize how AI clients discover and consume tools, resources, and prompts

These technologies can work together. An MCP server can wrap a REST API; a tool can run a RAG search; and the host can turn discovered tools into functions available to the model.

Practical example: MCP for support tickets

We'll build a local server with no external dependencies. The data will live in memory so we can focus on MCP.

The server will publish:

  • get_ticket: looks up a ticket;
  • update_ticket_status: updates its status;
  • support://playbook: offers a guide as a resource;
  • analyze_ticket: generates a prompt to analyze a ticket.

Requirements

  • Node.js 20 or later for the server;
  • Node.js 22.19 or later if you want to use the current version of MCP Inspector;
  • npm;
  • Codex, optionally, to test it from a real agent.

1. Create the project

mkdir mcp-ticket-server
cd mcp-ticket-server
npm init -y
npm install @modelcontextprotocol/server zod
npm install -D typescript @types/node
mkdir src

The current official SDK family separates the server and client packages. In this example we only install the server.

2. Configure package.json

Replace its contents with:

{
  "name": "mcp-ticket-server",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "scripts": {
    "build": "tsc",
    "start": "node build/index.js",
    "inspect": "npm run build && npx @modelcontextprotocol/inspector node build/index.js"
  },
  "dependencies": {
    "@modelcontextprotocol/server": "latest",
    "zod": "latest"
  },
  "devDependencies": {
    "@types/node": "latest",
    "typescript": "latest"
  }
}

For a tutorial, latest avoids pinning a version that will soon go stale. In a real project, keep the package-lock.json and use fixed, reviewed versions.

3. Create tsconfig.json

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "Node16",
    "moduleResolution": "Node16",
    "types": ["node"],
    "rootDir": "./src",
    "outDir": "./build",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  },
  "include": ["src/**/*.ts"]
}

4. Create src/index.ts

import { McpServer } from "@modelcontextprotocol/server";
import { StdioServerTransport } from "@modelcontextprotocol/server/stdio";
import * as z from "zod/v4";

type TicketStatus = "open" | "in_progress" | "resolved";

interface Ticket {
  id: number;
  title: string;
  priority: "low" | "medium" | "high";
  status: TicketStatus;
}

const tickets = new Map<number, Ticket>([
  [
    101,
    {
      id: 101,
      title: "User can't log in",
      priority: "high",
      status: "open",
    },
  ],
  [
    102,
    {
      id: 102,
      title: "Monthly report is too slow",
      priority: "medium",
      status: "in_progress",
    },
  ],
]);

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

server.registerTool(
  "get_ticket",
  {
    title: "Get ticket",
    description: "Fetches a support ticket by its numeric identifier.",
    inputSchema: z.object({
      id: z.number().int().positive().describe("Ticket ID"),
    }),
  },
  async ({ id }) => {
    const ticket = tickets.get(id);

    if (!ticket) {
      return {
        isError: true,
        content: [{ type: "text", text: `Ticket ${id} does not exist.` }],
      };
    }

    return {
      content: [{ type: "text", text: JSON.stringify(ticket, null, 2) }],
      structuredContent: { ...ticket },
    };
  },
);

server.registerTool(
  "update_ticket_status",
  {
    title: "Update ticket status",
    description:
      "Changes a ticket's status. This is a write operation and must be confirmed by the user.",
    inputSchema: z.object({
      id: z.number().int().positive(),
      status: z.enum(["open", "in_progress", "resolved"]),
    }),
  },
  async ({ id, status }) => {
    const ticket = tickets.get(id);

    if (!ticket) {
      return {
        isError: true,
        content: [{ type: "text", text: `Ticket ${id} does not exist.` }],
      };
    }

    const previousStatus = ticket.status;
    ticket.status = status;

    return {
      content: [
        {
          type: "text",
          text: `Ticket ${id}: ${previousStatus} → ${status}`,
        },
      ],
      structuredContent: { ...ticket },
    };
  },
);

server.registerResource(
  "support-playbook",
  "support://playbook",
  {
    title: "Support playbook",
    description: "Rules for analyzing and closing tickets.",
    mimeType: "text/markdown",
  },
  async (uri) => ({
    contents: [
      {
        uri: uri.href,
        mimeType: "text/markdown",
        text: [
          "# Support playbook",
          "",
          "1. Confirm the symptom and its impact.",
          "2. Review evidence before changing the status.",
          "3. Don't mark a ticket as resolved without a verifiable fix.",
          "4. Summarize the cause, the fix, and the validation performed.",
        ].join("\n"),
      },
    ],
  }),
);

server.registerPrompt(
  "analyze_ticket",
  {
    title: "Analyze ticket",
    description: "Creates a structured instruction to analyze a ticket.",
    argsSchema: z.object({
      ticketId: z.string().describe("Ticket ID"),
    }),
  },
  ({ ticketId }) => ({
    messages: [
      {
        role: "user",
        content: {
          type: "text",
          text: [
            `Analyze ticket ${ticketId}.`,
            "First look up its data and use the support playbook.",
            "Explain the impact, likely cause, and next step.",
            "Don't change its status without my confirmation.",
          ].join(" "),
        },
      },
    ],
  }),
);

async function main(): Promise<void> {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Ticket MCP server running over stdio");
}

main().catch((error: unknown) => {
  console.error("Fatal error:", error);
  process.exit(1);
});

What does this code do?

McpServer keeps the catalog of capabilities. Each registration includes a description that helps the model decide when to use it, and a Zod schema that validates the input.

StdioServerTransport communicates between client and server over standard input and output. It's a good choice for local integrations because the host starts the process for you.

There's an important detail here: in a stdio server, stdout is reserved for protocol messages. That's why logs use console.error, which writes to stderr. A console.log could corrupt the communication.

Tickets are stored in memory. Restarting the server discards any changes. That's intentional for this test; later you could swap the Map for PostgreSQL, SQL Server, or an API.

5. Build

npm run build

You should get the file build/index.js.

6. Test everything with MCP Inspector

Run:

npm run inspect

Inspector will show a local URL with a temporary token. Open it in your browser and try the following:

  1. The node might show as Disconnected — connect it.

  2. Once connected, go to Tools and run get_ticket with:

    { "id": 101 }
    
  3. Run update_ticket_status with:

    { "id": 101, "status": "resolved" }
    
  4. Look up the ticket again and confirm the change.

  5. In Resources, open support://playbook (Support playbook).

  6. In Prompts, select analyze_ticket and use 101 as the argument.

You can also check the tools from the terminal:

npx @modelcontextprotocol/inspector --cli \
  node build/index.js \
  --method tools/list

If the server starts but Inspector shows nothing, first check that you've built the project and that there's no console.log writing to stdout.

Connecting it to Codex

From the project directory, get its absolute path:

pwd

Then register the server, replacing the example path:

codex mcp add ticket-support -- \
  node /absolute/path/mcp-ticket-server/build/index.js

Check the configuration:

codex mcp list

In Codex you can use /mcp to see the active servers. Then try natural requests:

Look up ticket 101 and summarize its priority and status.

Use the support playbook to analyze ticket 101.

Change ticket 101 to resolved.

The third request modifies data. A good host should surface the invocation and let the user reject it. Even so, real authorization must also exist on the server side; never rely solely on the model "behaving well."

Optional manual configuration

Codex lets you declare local servers in ~/.codex/config.toml, or, for a trusted project, in .codex/config.toml:

[mcp_servers.ticket-support]
command = "node"
args = ["/absolute/path/mcp-ticket-server/build/index.js"]

Local servers use command and args. Remote servers use a url and typically some authentication mechanism.

From demo to production

The example teaches the protocol, but a real server needs more controls.

Authentication and authorization

Knowing who connected isn't enough. Every operation must check what that identity is allowed to do. A user who can view tickets shouldn't necessarily be able to close them.

Principle of least privilege

Expose only the necessary capabilities. Don't publish a generic tool like execute_sql if you can offer limited operations like get_ticket or list_open_tickets.

Confirmation for sensitive actions

Creating invoices, deleting data, sending emails, or deploying to production requires explicit controls. Design tools so read and write actions are clearly distinguishable.

Server-side validation

The schema validates the shape of the arguments, but business logic must validate permissions, state transitions, limits, duplicates, and invariants.

Idempotency

A call can be retried. For operations like creating invoices or processing payments, use an idempotency key to avoid duplicate results.

Auditing

Log who requested the action, which tool ran, on what entity, when it happened, and what the result was. Don't write secrets or unnecessary personal data into the logs.

Secrets

Use environment variables or a secrets manager. Never put tokens or passwords in the code, in a tool's description, or in its response.

Useful errors

Return messages the agent can interpret without leaking sensitive information. For expected tool errors, return isError: true along with a safe explanation.

Transport

  • stdio: suitable for a local server started by the host;
  • Streamable HTTP: suitable for a remote service shared by multiple users or teams.

Moving from stdio to HTTP isn't just about opening a port. You need to add authentication, authorization, TLS, abuse protection, rate limits, observability, and isolation between users.

How to design good tools

A well-designed tool should be:

  • specific: close_ticket communicates intent better than execute_action;
  • small: one main responsibility per operation;
  • descriptive: the model relies on the description to choose it;
  • validated: arguments constrained by clear types and rules;
  • predictable: results with a stable structure;
  • secure: permissions and confirmations matching the impact;
  • observable: errors and activity are traceable.

Avoid vague descriptions like "handles tickets." It's better to write "Fetches a ticket by its numeric ID, without modifying it."

Common mistakes when getting started

Thinking MCP is the model

MCP doesn't reason or generate text. The model lives in the host; MCP defines how it communicates with external capabilities.

Giving direct access to the whole database

The agent should receive focused operations with reduced permissions. Broad access increases the risk of leaks and accidental changes.

Confusing a resource with a tool

If the consumer only needs to read identifiable information, consider a resource. If it needs to run an operation or a parameterized query, a tool is usually the right fit.

Trusting security to the prompt

"Don't delete data without permission" is a helpful instruction, not a security control. Authorization has to be enforced in code.

Using stdout for logs in stdio

Diagnostic messages must go to stderr. stdout carries the MCP messages.

Making giant tools

A tool that takes a free-form instruction and can execute any action is hard to control, test, and audit. Prefer concrete capabilities.

Checklist

Before publishing an MCP server, confirm:

  • [ ] Tools have unambiguous names and descriptions.
  • [ ] All inputs are validated.
  • [ ] Reads and writes are clearly separated.
  • [ ] Sensitive operations require proper authorization.
  • [ ] The server applies least privilege.
  • [ ] Secrets don't appear in code or responses.
  • [ ] Expected errors are returned in a structured way.
  • [ ] Critical operations are idempotent where appropriate.
  • [ ] Auditing exists without unnecessary sensitive data.
  • [ ] The server has been tested with MCP Inspector.
  • [ ] No logs are written to stdout when using stdio.
  • [ ] Dependencies are pinned and reviewed for production.

Ideas to extend the example

Once you have the basic server working, try:

  1. replacing the Map with PostgreSQL;
  2. adding list_tickets with filters by priority and status;
  3. requiring a reason when resolving a ticket;
  4. blocking a direct transition from open to resolved;
  5. keeping a history of changes;
  6. adding automated tests;
  7. creating a remote version with Streamable HTTP and authentication;
  8. connecting the same server from another MCP client and checking its portability.

Conclusion

MCP turns a one-off integration into a capability that agents can discover and use through a common standard. Its value isn't in replacing your APIs, databases, or business rules, but in providing a consistent, controllable boundary between those pieces and AI applications.

To get started, pick a small, low-risk use case: querying tickets, reading documentation, or running a calculation. Define one concrete tool, test it with Inspector, and connect it to a host. Once the flow is clear, add persistence, authentication, and production controls.

The example in this article already contains the three fundamental pieces — tools, resources, and prompts — and can serve as a base for building an MCP server for QuickBooks, Monday, Azure DevOps, Keycloak, or any system you want to make available to your agents.

Official sources


Article updated in August 2026. MCP and its SDKs evolve quickly; verify versions and official documentation before starting a production implementation.