Skip to content

Building Your First AI Agent with Claude and MCP

Posted on:March 25, 2026

Hey there, developers 🖖

Welcome to what I hope will be the first of many posts about AI Agents! This is a topic that I have been exploring quite a lot recently, and I’m excited to share with you what I’ve learned so far.

If you’ve been following the AI space, you’ve probably heard a lot about “AI Agents” everywhere. It’s the buzzword of 2026, no doubt. But behind all the hype, there’s a genuinely powerful concept that I believe every developer should understand and know how to build. So, that’s exactly what we’re going to do today — build our first AI Agent using Claude and the Model Context Protocol (MCP).

Let’s get started, developers!

Introduction

Well, you might be thinking: “Okay, but what exactly IS an AI Agent?”. I don’t blame you, it’s a fair question. The term has been used so loosely that it can mean different things depending on who you ask 😅

However, for us developers, let’s keep it simple and practical. Before we get to a more formal definition, let me ask you a question: have you ever used ChatGPT or Claude to help you with a coding task, and then had to manually copy the result, open a file, paste the code, run it, check the output, go back to the chat, and repeat the whole thing?

Yeah…that’s not an agent. That’s a chatbot with extra steps!

An AI Agent, on the other hand, is a system that can do things on its own. It can read your files, search the web, query your database, create pull requests, and more — all while reasoning about what to do next. The key difference is: a chatbot answers, an agent acts.

What is an AI Agent?

Let’s break it down into the components, shall we?

  1. A language model (LLM) - the brain. It understands your intent and reasons about what to do. In our case, Claude.
  2. Tools — the hands. These are functions the agent can call to interact with the real world (read files, call APIs, query databases, etc.).
  3. An orchestration loop — the nervous system. The agent receives a task, decides which tools to use, executes them, observes the result, and decides what to do next.

That’s it. Seriously. At its core, an AI Agent is just an LLM that can call functions in a loop until the task is done. Of course, it gets more complex when you start thinking about error handling, memory, multi-step reasoning, and so on. But the foundation is surprisingly simple.

You might be wondering: “That sounds cool, but how does Claude know what tools are available?”. Excellent question! That’s exactly where MCP comes in.

What is MCP?

MCP stands for Model Context Protocol. It’s an open-source standard created by Anthropic that defines how AI models connect to external tools and data sources. Think of it as the USB-C of AI — a universal connector that lets any AI model talk to any tool through a standardized interface.

Before MCP, if you wanted Claude to interact with your database, you had to write custom code for that specific integration. If you then wanted it to also interact with GitHub, you needed another custom integration. And if you switched from Claude to another model? Start over.

MCP solves this by providing a common protocol:

  1. MCP Servers expose tools (functions) that an AI can call. For example, a “filesystem” MCP server might expose tools like read_file, write_file, and list_directory.
  2. MCP Clients (like Claude Desktop or Claude Code) connect to these servers and make the tools available to the model.
  3. The communication between client and server follows a standard format, so any client can talk to any server.

The beautiful part? You can build an MCP server once, and it works with Claude, with Cursor, with any MCP-compatible client. The ecosystem already has thousands of servers available for databases, APIs, cloud services, and more.

Enough theory, let’s build something!

Project Setup

I think we could build an MCP server that gives Claude the ability to manage a simple task list. Yes, I know — a to-do app. The goal here is not to build the next productivity unicorn, it’s to understand the mechanics of how an agent works through MCP. Once you get this, you can build anything.

First, let’s set up our project. We’ll use TypeScript and the official @modelcontextprotocol/sdk:

mkdir agent-todo-mcp
cd agent-todo-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript @types/node tsx

Next, let’s configure TypeScript. Create a tsconfig.json:

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

And add these scripts to your package.json (the one generated by npm init -y):

{
  "scripts": {
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Cool, easy right? Now let’s build the actual MCP server.

Creating Our First MCP Server

This is where the magic happens, developer. We’re going to create an MCP server that exposes tools for managing tasks. Claude will be able to call these tools to add tasks, list tasks, complete tasks, and delete tasks.

Create the file src/index.ts:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// Our in-memory task store
interface Task {
  id: string;
  title: string;
  description?: string;
  status: "pending" | "in-progress" | "done";
  createdAt: string;
}
 
const tasks: Map<string, Task> = new Map();
let nextId = 1;
 
// Create the MCP server
const server = new McpServer({
  name: "todo-agent",
  version: "1.0.0",
});

See what we did there? We created a basic MCP server instance and a simple in-memory store for our tasks. Nothing fancy yet, but the foundation is solid ✌️

Now, let’s register our first tool — the ability to add a task:

// Tool: Add a new task
server.registerTool(
  "add_task",
  {
    description: "Create a new task with a title and optional description",
    inputSchema: {
      title: z.string().describe("The title of the task"),
      description: z
        .string()
        .optional()
        .describe("A detailed description of the task"),
    },
  },
  async ({ title, description }) => {
    const id = String(nextId++);
    const task: Task = {
      id,
      title,
      description,
      status: "pending",
      createdAt: new Date().toISOString(),
    };
    tasks.set(id, task);
 
    return {
      content: [
        {
          type: "text",
          text: `Task created successfully!\n\nID: ${id}\nTitle: ${title}\nStatus: pending`,
        },
      ],
    };
  },
);

Let me guide you through what’s happening here, developer:

  1. We registered a tool called add_task with the server.
  2. We provided a human-readable description — this is what Claude reads to understand when and how to use this tool.
  3. We defined the input schema using zod — Claude knows exactly what parameters to send.
  4. We wrote the handler function that actually creates the task and returns a result.

That’s the pattern. Every tool you build follows this same structure: name, description, schema, handler. Simple as that!

Connecting Claude to Our Server

Now, the question is: how do we tell Claude about our server? There are a few ways to do this, but the simplest one for development is through Claude Desktop.

First, let’s make sure our server can run. Add this at the bottom of src/index.ts:

// Start the server using stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Todo MCP Server running on stdio");
}
 
main().catch(console.error);

Build and make sure it compiles:

npx tsc

Now, to connect Claude Desktop to our server, we need to edit the configuration file. On macOS, it’s located at ~/Library/Application Support/Claude/claude_desktop_config.json. On Windows, it’s at %APPDATA%\Claude\claude_desktop_config.json:

{
  "mcpServers": {
    "todo-agent": {
      "command": "node",
      "args": ["/absolute/path/to/agent-todo-mcp/dist/index.js"]
    }
  }
}

Alternative: Using Claude Code CLI

Claude Code is Anthropic’s terminal-based coding agent — a standalone CLI tool that lives in your terminal and can interact with your codebase. It also supports MCP, so we can register our server directly from the command line. Pretty cool, right?

**⚠️ Heads up ** Claude Code is a standalone tool, it’s not an npm package. If you try to run npx claude or npm exec claude, you’ll get an error like could not determine executable to run because npm will find an unrelated claude package on the registry. Don’t fall into that trap!

To install Claude Code, use the native installer (no Node.js dependency needed):

macOS / Linux:

curl -fsSL https://claude.ai/install.sh | bash

Windows (PowerShell):

irm https://claude.ai/install.ps1 | iex

After installing, open a new terminal window so your PATH updates, and verify the installation:

claude --version

Then authenticate by running claude and following the browser prompts. Note that Claude Code requires a paid plan (Pro, Max, Teams, or Enterprise) — the free Claude.ai plan does not include access.

Once Claude Code is ready, you can register our MCP server:

claude mcp add --transport stdio todo-agent -- node /absolute/path/to/agent-todo-mcp/dist/index.js

If you don’t want to install Claude Code right now, no worries at all — the Desktop config approach above works just as well for our purposes.

Alright developer, now try asking Claude: “Add a task called ‘Learn about MCP’ with the description ‘Build my first MCP server and connect it to Claude’”. If everything is connected properly, you should see something like this in your terminal:

Add a task called 'Learn about MCP' with the description 'Build my first MCP server and connect it to Claude'
 
todo-agent - add_task (MCP)(title: "Learn about MCP", description: "Build my first MCP server and connect it to Claude")
Task created successfully!
 
     ID: 1
     Title: Learn about MCP
     Status: pending
 
Task "Learn about MCP" has been created with the description
  "Build my first MCP server and connect it to Claude".
  It's assigned ID 1 with a pending status.

See that todo-agent - add_task (MCP) line? That’s Claude calling our tool through the Model Context Protocol. Our agent is alive, developer!

Building a Useful Agent

Alright, I assume you are just like me, not satisfied when something “just” works… we need more tools to make this agent actually useful. Let’s add the ability to list, update, and delete tasks.

Back to src/index.ts, let’s add more tools:

// Tool: List all tasks
server.registerTool(
  "list_tasks",
  {
    description: "List all tasks, optionally filtered by status",
    inputSchema: {
      status: z
        .enum(["pending", "in-progress", "done", "all"])
        .optional()
        .default("all")
        .describe("Filter tasks by status"),
    },
  },
  async ({ status }) => {
    let filteredTasks = Array.from(tasks.values());
 
    if (status !== "all") {
      filteredTasks = filteredTasks.filter((t) => t.status === status);
    }
 
    if (filteredTasks.length === 0) {
      return {
        content: [{ type: "text", text: "No tasks found." }],
      };
    }
 
    const taskList = filteredTasks
      .map(
        (t) =>
          `- [${t.status === "done" ? "x" : " "}] #${t.id}: ${t.title}${
            t.description ? `\n  ${t.description}` : ""
          }`,
      )
      .join("\n");
 
    return {
      content: [
        {
          type: "text",
          text: `Tasks (${filteredTasks.length}):\n\n${taskList}`,
        },
      ],
    };
  },
);
 
// Tool: Update task status
server.registerTool(
  "update_task",
  {
    description: "Update the status of a task",
    inputSchema: {
      id: z.string().describe("The ID of the task to update"),
      status: z
        .enum(["pending", "in-progress", "done"])
        .describe("The new status of the task"),
    },
  },
  async ({ id, status }) => {
    const task = tasks.get(id);
 
    if (!task) {
      return {
        content: [{ type: "text", text: `Task #${id} not found.` }],
        isError: true,
      };
    }
 
    const oldStatus = task.status;
    task.status = status;
 
    return {
      content: [
        {
          type: "text",
          text: `Task #${id} updated: ${oldStatus}${status}\n\nTitle: ${task.title}`,
        },
      ],
    };
  },
);
 
// Tool: Delete a task
server.registerTool(
  "delete_task",
  {
    description: "Delete a task by its ID",
    inputSchema: {
      id: z.string().describe("The ID of the task to delete"),
    },
  },
  async ({ id }) => {
    const task = tasks.get(id);
 
    if (!task) {
      return {
        content: [{ type: "text", text: `Task #${id} not found.` }],
        isError: true,
      };
    }
 
    tasks.delete(id);
 
    return {
      content: [
        {
          type: "text",
          text: `Task #${id} ("${task.title}") has been deleted.`,
        },
      ],
    };
  },
);

See the pattern? Each tool is self-contained and describes itself clearly. This is important because the quality of your tool descriptions directly impacts how well the agent performs. Claude reads these descriptions to decide which tool to use and when. Think of it as writing good function documentation — but this time, it’s not for your colleagues, it’s for the AI!

Adding More Tools

Let’s go one step further and add a tool that makes our agent more intelligent — a summary tool that gives Claude the ability to analyze the current state of tasks:

// Tool: Get a summary of all tasks
server.registerTool(
  "get_task_summary",
  {
    description: "Get a summary with counts of tasks by status",
    inputSchema: {},
  },
  async () => {
    const all = Array.from(tasks.values());
    const pending = all.filter((t) => t.status === "pending").length;
    const inProgress = all.filter((t) => t.status === "in-progress").length;
    const done = all.filter((t) => t.status === "done").length;
 
    return {
      content: [
        {
          type: "text",
          text: `Task Summary:\n\n📋 Total: ${all.length}\n⏳ Pending: ${pending}\n🔄 In Progress: ${inProgress}\n✅ Done: ${done}\n\nCompletion rate: ${
            all.length > 0 ? Math.round((done / all.length) * 100) : 0
          }%`,
        },
      ],
    };
  },
);

Now, here’s where it gets interesting. With all these tools registered, Claude can chain them together automatically. You can say something like:

“Add three tasks for this week: review the PR for the auth feature, write unit tests for the payment module, and update the API documentation. Then mark the PR review as in-progress and show me a summary.”

Claude will call add_task three times, then update_task once, then get_task_summary — all in sequence, reasoning about each step. That’s the agent loop in action, developer!

Testing Our Agent

Before we wrap up, let’s make sure everything works. Build the project:

npx tsc

Here’s the complete src/index.ts for reference, with everything put together:

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
 
// Our in-memory task store
interface Task {
  id: string;
  title: string;
  description?: string;
  status: "pending" | "in-progress" | "done";
  createdAt: string;
}
 
const tasks: Map<string, Task> = new Map();
let nextId = 1;
 
// Create the MCP server
const server = new McpServer({
  name: "todo-agent",
  version: "1.0.0",
});
 
// Tool: Add a new task
server.registerTool(
  "add_task",
  {
    description: "Create a new task with a title and optional description",
    inputSchema: {
      title: z.string().describe("The title of the task"),
      description: z
        .string()
        .optional()
        .describe("A detailed description of the task"),
    },
  },
  async ({ title, description }) => {
    const id = String(nextId++);
    const task: Task = {
      id,
      title,
      description,
      status: "pending",
      createdAt: new Date().toISOString(),
    };
    tasks.set(id, task);
 
    return {
      content: [
        {
          type: "text",
          text: `Task created successfully!\n\nID: ${id}\nTitle: ${title}\nStatus: pending`,
        },
      ],
    };
  },
);
 
// Tool: List all tasks
server.registerTool(
  "list_tasks",
  {
    description: "List all tasks, optionally filtered by status",
    inputSchema: {
      status: z
        .enum(["pending", "in-progress", "done", "all"])
        .optional()
        .default("all")
        .describe("Filter tasks by status"),
    },
  },
  async ({ status }) => {
    let filteredTasks = Array.from(tasks.values());
 
    if (status !== "all") {
      filteredTasks = filteredTasks.filter((t) => t.status === status);
    }
 
    if (filteredTasks.length === 0) {
      return {
        content: [{ type: "text", text: "No tasks found." }],
      };
    }
 
    const taskList = filteredTasks
      .map(
        (t) =>
          `- [${t.status === "done" ? "x" : " "}] #${t.id}: ${t.title}${
            t.description ? `\n  ${t.description}` : ""
          }`,
      )
      .join("\n");
 
    return {
      content: [
        {
          type: "text",
          text: `Tasks (${filteredTasks.length}):\n\n${taskList}`,
        },
      ],
    };
  },
);
 
// Tool: Update task status
server.registerTool(
  "update_task",
  {
    description: "Update the status of a task",
    inputSchema: {
      id: z.string().describe("The ID of the task to update"),
      status: z
        .enum(["pending", "in-progress", "done"])
        .describe("The new status of the task"),
    },
  },
  async ({ id, status }) => {
    const task = tasks.get(id);
 
    if (!task) {
      return {
        content: [{ type: "text", text: `Task #${id} not found.` }],
        isError: true,
      };
    }
 
    const oldStatus = task.status;
    task.status = status;
 
    return {
      content: [
        {
          type: "text",
          text: `Task #${id} updated: ${oldStatus}${status}\n\nTitle: ${task.title}`,
        },
      ],
    };
  },
);
 
// Tool: Delete a task
server.registerTool(
  "delete_task",
  {
    description: "Delete a task by its ID",
    inputSchema: {
      id: z.string().describe("The ID of the task to delete"),
    },
  },
  async ({ id }) => {
    const task = tasks.get(id);
 
    if (!task) {
      return {
        content: [{ type: "text", text: `Task #${id} not found.` }],
        isError: true,
      };
    }
 
    tasks.delete(id);
 
    return {
      content: [
        {
          type: "text",
          text: `Task #${id} ("${task.title}") has been deleted.`,
        },
      ],
    };
  },
);
 
// Tool: Get a summary of all tasks
server.registerTool(
  "get_task_summary",
  {
    description: "Get a summary with counts of tasks by status",
    inputSchema: {},
  },
  async () => {
    const all = Array.from(tasks.values());
    const pending = all.filter((t) => t.status === "pending").length;
    const inProgress = all.filter((t) => t.status === "in-progress").length;
    const done = all.filter((t) => t.status === "done").length;
 
    return {
      content: [
        {
          type: "text",
          text: `Task Summary:\n\n📋 Total: ${all.length}\n⏳ Pending: ${pending}\n🔄 In Progress: ${inProgress}\n✅ Done: ${done}\n\nCompletion rate: ${
            all.length > 0 ? Math.round((done / all.length) * 100) : 0
          }%`,
        },
      ],
    };
  },
);
 
// Start the server using stdio transport
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Todo MCP Server running on stdio");
}
 
main().catch(console.error);

Restart Claude Desktop (or reconnect via Claude Code), and try these prompts:

  1. “Add a task: Set up CI/CD pipeline for the new microservice”
  2. “List all my tasks”
  3. “Mark task 1 as in-progress”
  4. “Give me a summary of my tasks”

If everything works, you should see Claude using the tools naturally, as if it always knew how to manage your tasks. That’s the power of MCP — you define the capabilities, and Claude figures out the rest!

Next Steps

Ladies and Gents, to avoid this post getting too long, I believe we have got to a good level of understanding of AI Agents and MCP.

But this is just the beginning! There’s so much more to explore, and I plan to cover these topics in future posts:

  1. Multi-Agent Orchestration — what happens when you have multiple specialized agents working together.
  2. Persistent Memory — how to give your agent memory across sessions so it remembers context.
  3. Production Architecture — error handling, retry strategies, human-in-the-loop checkpoints, and security hardening.
  4. Agent-to-Agent Communication (A2A) — the protocol that lets agents talk to each other.

What do you think? Which topic would you like me to cover next? Let me know!

Useful Resources

Conclusion

A huge thank you to everyone that followed me in this journey to build our first AI Agent. I hope you enjoyed it!

What we built today is simple, but the concepts are exactly the same ones used in production AI systems. The pattern is always: LLM + Tools + Orchestration Loop. MCP just makes the “Tools” part standardized and portable.

The AI Agents space is moving incredibly fast, and I truly believe that understanding how to build them is one of the most valuable skills a developer can have right now.

If you have any questions, feedback, or suggestions, please don’t hesitate to reach out. Your opinion is very valuable to me, developer.

Take care, be kind to each other, and see you in the next post!