Developing MCP Service Tools

Introduction & Lesson Overview

Welcome back! In our last lesson, we explored how to define and expose your MCP server's capabilities using tools, resources, and prompts. We also practiced interacting with these primitives from a client, giving us a solid foundation for building interactive and discoverable MCP integrations.

Now we're ready to take the next step together by focusing on MCP tools and how we can use them to power real-world services. Tools are the primary way our server performs actions for clients, and understanding how to design and implement them is essential for building useful MCP applications.

For this lesson, we'll work together on building a shopping list manager as our example. This service will let us add, remove, and update items in a shopping list, just like we might do in a real app. By the end of this lesson, we'll know how to define a service in TypeScript, expose its features as MCP tools, and connect everything so that clients can interact with our server in a meaningful way.

Understanding MCP Tools in the Current Ecosystem

Let's start by understanding why tools are absolutely critical when building for AI agents. In our previous lesson, we learned that MCP servers can expose three main types of primitives: tools, resources, and prompts. However, when it comes to real-world agent integrations, tools are by far the most important.

Since the MCP protocol is still relatively new, most agent applications and AI platforms currently only support the tools primitive. This means that while resources and prompts are part of the specification, our primary focus should be on designing excellent tools if we want our MCP server to work with the majority of agent systems available today.

Think of a tool in MCP as a function or an action that our server can perform on request. For example, a tool might add two numbers, fetch a record, or update a setting. Tools are registered with the server and described using input schemas, so agents know exactly what arguments to provide and what to expect in return. This makes tools the backbone of agent-server interactions and the key to building MCP servers that agents can actually use.

In this lesson, we'll see how tools can wrap the logic of a real service — our shopping list manager — so that agents can add, remove, and update items just by calling the right tool. This tool-first approach is essential for building MCP servers that work seamlessly with today's agent ecosystem.

Building Our Shopping List Service

Imagine you have a service that manages a shopping list and you want to expose this functionality to AI agents through MCP tools. This is exactly the kind of real-world scenario where MCP shines - taking existing business logic and making it accessible to agents so they can help users add items, mark things as purchased, and manage their shopping lists.

We'll define this service in a TypeScript class called ShoppingListService, which will be responsible for managing our shopping list data and providing methods to interact with it. Once we have this service, we'll wrap its functionality with MCP tools to create a seamless interface for agents.

Here's our service:

import { randomUUID } from 'crypto';

interface ShoppingItem {
  id: string;
  name: string;
  quantity: number;
  purchased: boolean;
}

export class ShoppingListService {
  private items: ShoppingItem[];

  constructor() {
    this.items = [
      { id: randomUUID(), name: "Milk", quantity: 2, purchased: true },
      { id: randomUUID(), name: "Bread", quantity: 1, purchased: false },
      { id: randomUUID(), name: "Eggs", quantity: 12, purchased: true },
      { id: randomUUID(), name: "Apples", quantity: 6, purchased: false },
      { id: randomUUID(), name: "Coffee", quantity: 1, purchased: false }
    ];
  }

  getItems(purchased?: boolean): ShoppingItem[] {
    if (purchased === undefined) {
      return [...this.items];
    }
    return this.items.filter(item => item.purchased === purchased);
  }

  addItem(name: string, quantity: number): ShoppingItem {
    const newItemId = randomUUID();
    const newItem: ShoppingItem = {
      id: newItemId,
      name,
      quantity,
      purchased: false
    };
    this.items.push(newItem);
    return newItem;
  }

  removeItem(itemId: string): boolean {
    const index = this.items.findIndex(item => item.id === itemId);
    if (index !== -1) {
      this.items.splice(index, 1);
      return true;
    }
    return false;
  }

  setPurchased(itemId: string, purchased: boolean = true): boolean {
    const item = this.items.find(item => item.id === itemId);
    if (item) {
      item.purchased = purchased;
      return true;
    }
    return false;
  }
}

Our class keeps an array of shopping items, each with an ID, name, quantity, and purchased status. The service provides methods to get all items (optionally filtered by whether they are purchased), add a new item, remove an item by ID, and mark an item as purchased or not. We're using randomUUID() to ensure that each item has a unique identifier, which is important for tracking and updating items.

By keeping all the logic for managing the shopping list in one place, our service makes it easy to build tools that interact with the data in a safe and consistent way. This separation of concerns is a good practice in software design and will make our MCP server easier to maintain and extend.

Setting Up Our MCP Server Structure

Now that we have our service, let's set up the structure for our MCP server. We'll create a function that initializes both the server and the service, then registers all our tools:

import { z } from "zod";
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { ShoppingListService } from "./shopping-list-service.js";

export function createMcpServer(): { server: McpServer } {
  // Create server instance
  const server = new McpServer({
    name: "shopping-list-server",
    version: "1.0.0"
  });

  // Create service instance
  const service = new ShoppingListService();

  // Register all tools
  registerShoppingListTools(server, service);

  return { server };
}

function registerShoppingListTools(server: McpServer, service: ShoppingListService) {
  // Tool registrations go here...
}

This structure gives us a clean separation between server setup and tool registration. Each time we call createMcpServer(), we get a fresh server instance with its own service instance, which will be important when we want to support multiple clients with separate shopping lists.

Now we're ready to register each of our tools in the registerShoppingListTools function. We'll go through them one by one, starting with the most basic tool for retrieving items, then moving on to adding, updating, and removing items. Each tool will follow the same pattern: define the tool name, provide a clear description, specify the input schema using Zod, and implement the handler function that calls our service methods.

Registering the Get Items Tool

Now we're ready to register each of our tools in the registerShoppingListTools function. We'll start with the get_items tool that allows clients to retrieve shopping list items with optional filtering:

// Register tool to retrieve shopping list items
server.registerTool(
  "get_items", // Tool name that clients will call
  { 
    description: "Get shopping list items with optional filtering by purchase status",
    inputSchema: { 
      // Optional boolean parameter to filter by purchase status
      purchased: z.boolean().optional().describe("Filter by purchase status. If not provided, returns all items.")
    }
  },
  ({ purchased }) => {
    try {
      // Call our service method to get items
      const items = service.getItems(purchased);
      
      // Return structured success response
      return {
        content: [{ 
          type: "text", 
          text: JSON.stringify({
            success: true,
            message: `Retrieved ${items.length} item(s)`,
            data: items
          }, null, 2)
        }]
      };
    } catch (error) {
      // Return structured error response
      return {
        content: [{ 
          type: "text", 
          text: JSON.stringify({
            success: false,
            message: "Failed to retrieve items"
          }, null, 2)
        }]
      };
    }
  }
);

Notice how we're using z.boolean().optional() to make the purchased parameter optional. When called without arguments, it returns all items. When called with purchased: true or purchased: false, it filters accordingly. We're also wrapping our service call in a try-catch block and returning a structured JSON response with success status, message, and data.

We're returning our structured data as text content using JSON.stringify() because it's the most versatile format for our shopping list data. While the MCP SDK supports other content types like images, audio, and resource references, text is perfect for structured data that AI agents need to read and understand. The AI can directly interpret the JSON structure, understand success/failure status, and work with the shopping list data naturally without any additional processing. If you were building programmatic clients that need to manipulate this data, you'd use JSON.parse() to convert it back to JavaScript objects, but for AI agents, the readable JSON text format is ideal.

Registering the Add Item Tool

Next, we'll register our add_item tool that creates new shopping list items:

// Register tool to add new items to the shopping list
server.registerTool(
  "add_item", // Tool name for adding items
  {
    description: "Add a new item to the shopping list",
    inputSchema: {
      // Required string parameter for item name
      name: z.string().describe("Name of the item to add"),
      // Required positive number for quantity
      quantity: z.number().positive().describe("Quantity of the item")
    }
  },
  ({ name, quantity }) => {
    try {
      // Call our service method to create the new item
      const newItem = service.addItem(name, quantity);
      
      // Validate that we got a proper item back
      if (newItem && newItem.id) {
        return {
          content: [{ 
            type: "text", 
            text: JSON.stringify({
              success: true,
              message: "Item added successfully",
              data: newItem // Return the newly created item
            }, null, 2)
          }]
        };
      }
      
      // If validation fails, throw an error
      throw new Error("Invalid item returned");
    } catch (error) {
      // Return structured error response
      return {
        content: [{ 
          type: "text", 
          text: JSON.stringify({
            success: false,
            message: "Failed to add item"
          }, null, 2)
        }]
      };
    }
  }
);

Here we're using z.number().positive() to ensure the quantity is a positive number. We're also validating that our service returned a valid item with an ID before considering the operation successful. This extra validation helps us catch potential issues early.

Registering the Remove Item Tool

Now we'll add our remove_item tool that deletes items from the shopping list by ID:

// Register tool to remove items from the shopping list
server.registerTool(
  "remove_item", // Tool name for removing items
  {
    description: "Remove an item from the shopping list",
    inputSchema: {
      // Required string parameter for the item ID to remove
      itemId: z.string().describe("ID of the item to remove")
    }
  },
  ({ itemId }) => {
    // Call our service method to remove the item
    const success = service.removeItem(itemId);
    
    // Return response based on whether the item was found and removed
    return {
      content: [{ 
        type: "text", 
        text: JSON.stringify({
          success: success,
          message: success ? "Item removed successfully" : "Item not found"
        }, null, 2)
      }]
    };
  }
);

This tool has a simple input schema requiring only the item ID as a string. Our service method returns a boolean indicating whether the item was found and removed, which we use to provide appropriate success or error messages.

Registering the Set Purchased Tool

Finally, we'll register our set_purchased tool that updates the purchase status of items:

// Register tool to update item purchase status
server.registerTool(
  "set_purchased", // Tool name for updating purchase status
  {
    description: "Mark an item as purchased or not purchased",
    inputSchema: {
      // Required string parameter for the item ID to update
      itemId: z.string().describe("ID of the item to update"),
      // Optional boolean parameter that defaults to true
      purchased: z.boolean().default(true).describe("Purchase status to set")
    }
  },
  ({ itemId, purchased }) => {
    // Call our service method to update the purchase status
    const success = service.setPurchased(itemId, purchased);
    
    // Return response with dynamic message based on the operation
    return {
      content: [{ 
        type: "text", 
        text: JSON.stringify({
          success: success,
          message: success 
            ? `Item marked as ${purchased ? 'purchased' : 'not purchased'}`
            : "Item not found"
        }, null, 2)
      };
      ]
    }
  }
);

We're using z.boolean().default(true) to make the purchased parameter optional, defaulting to true when not specified. Our response message dynamically reflects whether the item was marked as purchased or not purchased, providing clear feedback to the client.

Each of our tools follows consistent patterns for error handling, response formatting, and input validation. This consistency makes our server predictable and reliable for clients, whether they are AI agents or other applications.

Exposing Tools with Stateful HTTP Transport

Now that we have our tools registered, we need to expose them so that multiple clients can access our service. However, there's an important consideration: we want each client to have their own separate shopping list, not share a single global list. This is where stateful sessions become crucial.

Our solution is to create a separate MCP server instance for each client session. When a client connects, they get their own ShoppingListService instance, which means their shopping list data is completely isolated from other clients. Here's how we implement this using HTTP transport:

import express, { Request, Response } from "express";
import { randomUUID } from "node:crypto";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
import { createMcpServer } from "./mcp-server.js";

// Create an Express app
const app = express();

// Middleware to parse JSON bodies from incoming requests
app.use(express.json());

// Store each session's transport by its session ID
const transports: Record<string, StreamableHTTPServerTransport> = {};

The key insight here is our transports object, which stores a separate transport instance for each session. Each transport is connected to its own MCP server instance, which in turn has its own ShoppingListService instance.

Our main request handler manages session lifecycle and transport creation:

// Handle POST requests to /mcp (main entry for tool calls)
app.post("/mcp", async (req: Request, res: Response) => {
  // Get the session ID from the headers (if this is not a new session)
  const sid = req.headers["mcp-session-id"] as string | undefined;
  // Try to get the transport for the session ID
  let transport: StreamableHTTPServerTransport | undefined = sid ? transports[sid] : undefined;

  // If there is no transport for the session ID, and this is an initialize request
  if (!transport && isInitializeRequest(req.body)) {
    // Create a new transport for this session
    transport = new StreamableHTTPServerTransport({
      // Generate a new session ID for this transport
      sessionIdGenerator: () => randomUUID(),
      // When the session is initialized, store the transport using its session ID
      onsessioninitialized: (id) => (transports[id] = transport as StreamableHTTPServerTransport),
    });

    // Create a new MCP server for this session
    const { server } = createMcpServer();

    // Connect the server to the transport
    await server.connect(transport);
  }

  // If there is still no transport, the request is invalid
  if (!transport) {
    return res.status(400).json({
      jsonrpc: "2.0",
      error: { code: -32000, message: "Bad Request: invalid session" },
      id: null,
    });
  }

  // Hand off the request to the transport, which will process it using MCP logic
  await transport.handleRequest(req, res, req.body);
});

// Start the web server on port 3000
const PORT = 3000;
app.listen(PORT, () =>
  console.log(`MCP server running at http://localhost:${PORT}/mcp`)
);

This implements session-based server instantiation. When a client sends an initialize request without an existing session, our server creates a new StreamableHTTPServerTransport with a unique session ID, calls createMcpServer() to create a fresh MCP server instance, connects the server to the transport, and stores the transport for future requests from this client.

Testing Our Tools with an MCP Client

Now that we have our shopping list tools registered and exposed via HTTP transport, let's test them using an MCP client. This is essential for verifying that our tools work correctly before integrating them with AI agents or other applications.

Let's set up our test client:

import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// Get the base URL of the MCP server
const baseUrl = new URL("http://localhost:3000/mcp");

// Create a client
const client = new Client({
  name: "shopping-list-client",
  version: "1.0.0"
});

// Create a transport for the MCP server
const transport = new StreamableHTTPClientTransport(baseUrl);

// Connect to the MCP server
await client.connect(transport);

Getting the Initial Shopping List

Let's start by retrieving the initial shopping list that comes pre-populated with sample data:

// 1. First get_items call - should show initial list
const initialItems = await client.callTool({ name: "get_items", arguments: {} });
console.log("Initial items:", ((initialItems as any).content[0] as any).text);

This produces output showing our default items:

Initial items: {
  "success": true,
  "message": "Retrieved 5 item(s)",
  "data": [
    {
      "id": "e44a02d1-d102-4ba5-a5a5-8e2ff08898f2",
      "name": "Milk",
      "quantity": 2,
      "purchased": true
    },
    {
      "id": "f55b13e2-e213-5cb6-b6b6-9f3ff19909f3",
      "name": "Bread",
      "quantity": 1,
      "purchased": false
    },
    // ... more items
  ]
}

Adding a New Item

Let's test our add_item tool by adding bananas to the shopping list:

// 2. Add one new item
const addResult = await client.callTool({ 
  name: "add_item", 
  arguments: {
    name: "Bananas",
    quantity: 3
  }
});
console.log("Add bananas:", ((addResult as any).content[0] as any).text);

// Extract the item ID from the structured response
const addResponse = JSON.parse(((addResult as any).content[0] as any).text);
const bananasId = addResponse.success ? addResponse.data.id : null;

The output shows our newly created item with its generated ID:

Add bananas: {
  "success": true,
  "message": "Item added successfully",
  "data": {
    "id": "j99f57i6-i657-9gfa-fafa-dg7ff5dd4dg7",
    "name": "Bananas",
    "quantity": 3,
    "purchased": false
  }
}

Notice how we extract the item ID from the response - this is important for subsequent operations that need to reference this specific item.

Testing Purchase Status Updates

Now let's test our set_purchased tool to mark our bananas as purchased:

// 4. Mark the new item as purchased
if (bananasId) {
  const purchaseResult = await client.callTool({ 
    name: "set_purchased", 
    arguments: {
      itemId: bananasId,
      purchased: true
    }
  });
  console.log("Mark bananas purchased:", ((purchaseResult as any).content[0] as any).text);
}

The output confirms the purchase status was updated:

Mark bananas purchased: {
  "success": true,
  "message": "Item marked as purchased"
}

Testing Item Removal

Finally, let's test our remove_item tool to clean up our test data:

// 5. Remove the item we just added
if (bananasId) {
  const removeResult = await client.callTool({ 
    name: "remove_item", 
    arguments: {
      itemId: bananasId
    }
  });
  console.log("Remove bananas:", ((removeResult as any).content[0] as any).text);
}

The output shows successful removal:

Remove bananas: {
  "success": true,
  "message": "Item removed successfully"
}

This comprehensive test demonstrates that all four of our tools work correctly: get_items retrieves data, add_item creates new items, set_purchased updates item status, and remove_item deletes items. The consistent JSON response format makes it easy to parse results and handle both success and error cases programmatically.

Summary & Next Steps

In this lesson, we've built a complete MCP service together! We started by defining a service in TypeScript to manage our application's data, then exposed its features as MCP tools using registerTool with proper input schemas and error handling. We connected each tool to a method on our service, creating a clean and maintainable way for clients to interact with our server.

We also implemented stateful HTTP transport so multiple clients can each have their own separate shopping lists, and we tested everything using an MCP client to verify our tools work correctly.

The key insights we've covered together are:

  • Tools are the most important MCP primitive for current agent integrations
  • Service separation keeps our business logic clean and testable
  • Consistent response formatting makes our server predictable and reliable
  • Session-based server instances enable multi-client support with data isolation
  • Comprehensive testing ensures our tools work before production use

Next, you'll get to implement and test these concepts yourself through hands-on practice exercises. You'll build your own MCP service tools and see how they integrate with real clients. Keep experimenting and exploring — this hands-on approach is the best way to master MCP tools and build useful, interactive services!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal