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."
- The client asks the server which tools it offers.
- The server describes
get_ticketand its parameter schema. - The model determines that this tool can resolve the request.
- The host requests approval when needed.
- The client sends a call with
{ "id": 101 }. - The server validates the argument, runs the logic, and returns the result.
- 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:
-
The node might show as Disconnected — connect it.
-
Once connected, go to Tools and run
get_ticketwith:{ "id": 101 } -
Run
update_ticket_statuswith:{ "id": 101, "status": "resolved" } -
Look up the ticket again and confirm the change.
-
In Resources, open
support://playbook(Support playbook). -
In Prompts, select
analyze_ticketand use101as 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_ticketcommunicates intent better thanexecute_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
stdoutwhen using stdio. - [ ] Dependencies are pinned and reviewed for production.
Ideas to extend the example
Once you have the basic server working, try:
- replacing the
Mapwith PostgreSQL; - adding
list_ticketswith filters by priority and status; - requiring a reason when resolving a ticket;
- blocking a direct transition from
opentoresolved; - keeping a history of changes;
- adding automated tests;
- creating a remote version with Streamable HTTP and authentication;
- 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
- Introduction to Model Context Protocol
- Official guide to building an MCP server
- MCP Inspector
- MCP tools specification
- Configuring MCP servers in Codex
- Official TypeScript SDK for MCP
Article updated in August 2026. MCP and its SDKs evolve quickly; verify versions and official documentation before starting a production implementation.
Comments ()