Lesson

Building MCP servers

In the Developing with Neo4j MCP Tools course, you learned the basics of the Model Context Protocol (MCP) and how to use the Neo4j Cypher MCP Server to enable AI agents to interact with Neo4j databases.

In this course, you will learn how to build your own MCP servers with graph-backed tools and resources, creating a complete GraphRAG application.

You will learn to build these tools with the MCP TypeScript SDK, and you will use the MCP Inspector to test your servers.

Emerging standards

The Model Context Protocol is an emerging standard for connecting AI applications with tools and data sources. As such the protocol is still evolving, and the features taught in this course are subject to change.

You can follow the Model Context Protocol documentation to stay up to date with the latest changes.

Understanding the MCP TypeScript SDK

The MCP TypeScript SDK is the official library that implements the full Model Context Protocol specification for TypeScript and JavaScript applications. It provides both client and server implementations, making it easy to create MCP servers that expose custom functionality to AI agents.

The SDK provides the McpServer class, a high-level interface for building servers. You register tools, resources, and prompts by calling methods on this class, and use Zod schemas to define typed input parameters.

Installing the MCP TypeScript SDK

To get started building MCP servers, you need to install the @modelcontextprotocol/sdk package along with zod:

bash
npm install @modelcontextprotocol/sdk zod

Zod is a TypeScript-first schema validation library. The MCP SDK uses Zod schemas to define the input parameters for tools and prompts. These schemas serve a dual purpose: they validate the data at runtime, and they generate the JSON Schema descriptions that tell the LLM what each parameter expects.

Creating a Server Instance

To create a new MCP server, import the McpServer class and instantiate it with a name and version:

typescript
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
 
const server = new McpServer({
  name: "My Server",
  version: "1.0.0",
});

Core MCP Features

MCP servers expose three types of features to clients.

1. Tools

Tools are functions that LLMs can call to perform actions or retrieve data. Tools are perfect for tasks that LLMs struggle with, like counting or complex calculations.

Characteristics:

  • Called by the LLM (model-controlled)
  • Can have side effects (create, update, delete)
  • Can perform computation
  • Return structured results

Use tools when:

  • You need to execute code or query a database
  • The action depends on user input or context
  • You want the LLM to decide when to use it
  • You need deterministic results

Here is a simple example of a tool that helps LLMs with counting - a task they typically struggle with:

typescript
import { z } from "zod";
 
server.registerTool("countLetters", {
  description: "Count occurrences of a letter in the text",
  inputSchema: {
    text: z.string().describe("The text to search in"),
    search: z.string().describe("The letter to count"),
  },
}, async ({ text, search }) => ({
  content: [{
    type: "text",
    text: String(text.toLowerCase().split(search.toLowerCase()).length - 1),
  }],
}));

2. Resources

Resources expose data that can be loaded into the LLM's context, similar to a REST API endpoint.

Characteristics:

  • Accessed by the client application (application-controlled)
  • Read-only (no side effects)
  • Typically static or parameterized URIs
  • Provide context for the LLM

Use resources when:

  • You want to expose data that doesn't change often
  • The client decides what to load (not the LLM)
  • You're providing reference information or documentation
  • You need to expose specific entities by ID

Example of a resource with a static URI:

typescript
server.registerResource(
  "greeting",
  "greeting://world",
  { description: "A simple greeting", mimeType: "text/plain" },
  async () => ({
    contents: [{ uri: "greeting://world", text: "Hello, World!" }],
  })
);

3. Prompts

Prompts are pre-defined templates that help users interact with your server effectively.

Characteristics:

  • Invoked by the user (user-controlled)
  • Provide reusable templates
  • Can accept parameters
  • Guide the conversation

Use prompts when:

  • You want to provide common workflows
  • Users need help formulating requests
  • You want to standardize interactions
  • You need to ensure consistent input format

Example of a prompt template:

typescript
server.registerPrompt("countLetters", {
  description: "Template for counting letter occurrences",
  argsSchema: {
    text: z.string(),
    search: z.string(),
  },
}, ({ text, search }) => ({
  messages: [{
    role: "user",
    content: {
      type: "text",
      text: `Count the occurrences of the letter '${search}' in the text:\n\n${text}`,
    },
  }],
}));

Putting It All Together

Here is a complete example showing all three features working together:

typescript:server/index.ts
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; // <1>
import { z } from "zod";
 
// Create an MCP server
const server = new McpServer({
  name: "Text Analysis Server",
  version: "1.0.0",
});
 
// Tool for deterministic counting
server.registerTool("countLetters", { // <2>
  description: "Count occurrences of a letter in the text",
  inputSchema: {
    text: z.string().describe("The text to search in"),
    search: z.string().describe("The letter to count"),
  },
}, async ({ text, search }) => ({
  content: [{
    type: "text",
    text: String(text.toLowerCase().split(search.toLowerCase()).length - 1),
  }],
}));
 
// Resource for reference data
server.registerResource( // <3>
  "greeting",
  "greeting://world",
  { description: "A simple greeting", mimeType: "text/plain" },
  async () => ({
    contents: [{ uri: "greeting://world", text: "Hello, World!" }],
  })
);
 
// Prompt template for common task
server.registerPrompt("countLetters", { // <4>
  description: "Template for letter counting task",
  argsSchema: {
    text: z.string(),
    search: z.string(),
  },
}, ({ text, search }) => ({
  messages: [{
    role: "user",
    content: {
      type: "text",
      text: `Count the occurrences of the letter '${search}' in the text:\n\n${text}`,
    },
  }],
}));

This code demonstrates:

  1. Importing the McpServer class and creating a server instance
  2. A tool that performs deterministic counting, with Zod schemas for input validation
  3. A resource that provides data at a static URI
  4. A prompt that helps users formulate requests

Registration Methods

The code sample uses methods on the McpServer instance to register MCP features. Let's take a closer look at the tool example:

typescript:server/index.ts
server.registerTool("countLetters", {
  description: "Count occurrences of a letter in the text",
  inputSchema: {
    text: z.string().describe("The text to search in"),
    search: z.string().describe("The letter to count"),
  },
}, async ({ text, search }) => ({
  content: [{
    type: "text",
    text: String(text.toLowerCase().split(search.toLowerCase()).length - 1),
  }],
}));

The registerTool method accepts three arguments:

  1. Name - The string "countLetters" identifies the tool to MCP clients
  2. Options - An object containing the description and inputSchema, where each input is defined with a Zod schema that provides type information and descriptions
  3. Handler - An async function that receives the validated inputs and returns the result

The description and Zod .describe() calls are important because they tell the LLM what the tool does and what each parameter expects. The LLM uses this information to decide when and how to call the tool.

Running the Server

To run the server, you connect it to a transport and start listening for connections. The most common transport for local development is stdio, which communicates over standard input and output:

typescript:server/index.ts
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
 
// ... server setup ...
 
async function main() {
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.error("Server running on stdio");
}
 
main().catch(console.error);

This starts the MCP server using the stdio transport method and begins listening for incoming connections from MCP clients.

You can then run the server using tsx:

bash
npx tsx server/index.ts

Transport Methods

In the Developing with Neo4j MCP Tools course, we also covered the different transport methods that can be used to connect to an MCP server; Standard Input/Output (stdio), and Streamable HTTP (streamable-http).

This course will focus on the stdio transport method. The stdio transport is the simplest approach and works well for local development and testing with tools like the MCP Inspector.

For web deployments, the Streamable HTTP transport exposes the tools through an HTTP server, with results streamed back to the client using Server-Sent Events (SSE).

Streamable HTTP transport

The TypeScript SDK also supports the Streamable HTTP transport for web deployments. You can use Express or another HTTP framework to serve your MCP server over HTTP.

See the MCP TypeScript SDK documentation for details on setting up HTTP transport.