Implementing MCP Servers with TypeScript

Model Context Protocol TypeScript Implementation

Introduction

In our previous guide, we explored building an MCP server using Python. Today, we'll implement the same functionality using TypeScript, showcasing how to build a robust MCP server that connects Claude (or any other MCP-compatible AI assistant) directly to your database.

TypeScript offers several advantages for MCP server implementation, including:

  • Strong typing that helps catch errors at compile time
  • Better code organization and maintainability
  • Native integration with Node.js ecosystem
  • Excellent tooling and IDE support

By the end of this guide, you'll have a fully functional TypeScript-based MCP server that connects to a SQLite database, allowing Claude to query your data while maintaining full control over your information.

Prerequisites

Before we begin, make sure you have the following:

  • Node.js 18+ installed
  • npm or yarn package manager
  • Basic knowledge of TypeScript and JavaScript
  • Familiarity with SQL and database concepts
  • Claude Desktop or another MCP-compatible client for testing

Don't worry if you're new to TypeScript - we'll walk through each step carefully.

The TypeScript MCP SDK

The MCP TypeScript SDK provides all the building blocks you need to create an MCP server. Released by the Model Context Protocol team, it handles the protocol details, connection management, and communication patterns so you can focus on implementing your database integration.

The core components of the SDK include:

  • MCPServer: The main server class that manages connections and routes requests
  • Resource: For making static or semi-static data available to the AI
  • Tool: For implementing functions that can be called by the AI
  • Type definitions for all protocol messages and objects

Project Setup

Let's start by creating a new TypeScript project and installing the necessary dependencies:


# Create a new directory for your project
mkdir mcp-sqlite-server
cd mcp-sqlite-server

# Initialize your project
npm init -y

# Install required dependencies
npm install @mcp/sdk@latest sqlite3 better-sqlite3 typescript ts-node @types/node

# Initialize TypeScript configuration
npx tsc --init
                        

Next, let's update the tsconfig.json file to configure TypeScript properly:


{
  "compilerOptions": {
    "target": "ES2020",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true,
    "skipLibCheck": true,
    "outDir": "./dist"
  },
  "include": ["src/**/*"]
}
                        

Let's create a basic folder structure for our project:


mkdir -p src/tools src/resources
touch src/index.ts src/database.ts
                        

Setting Up Database Connection

First, let's create a sample SQLite database that we'll use for our example. Create a file named src/database.ts with the following content:


import Database from 'better-sqlite3';
import { existsSync } from 'fs';
import { join } from 'path';

// Database configuration
const DB_PATH = join(process.cwd(), 'sample.db');

// Initialize the database
export function initializeDatabase(): Database.Database {
  const isNewDatabase = !existsSync(DB_PATH);
  const db = new Database(DB_PATH);
  
  // Create tables if this is a new database
  if (isNewDatabase) {
    console.log('Creating new database with sample data...');
    
    // Create products table
    db.exec(`
      CREATE TABLE products (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        category TEXT NOT NULL,
        price REAL NOT NULL,
        inventory INTEGER NOT NULL
      )
    `);
    
    // Create customers table
    db.exec(`
      CREATE TABLE customers (
        id INTEGER PRIMARY KEY,
        name TEXT NOT NULL,
        email TEXT UNIQUE NOT NULL,
        signup_date TEXT NOT NULL
      )
    `);
    
    // Create orders table
    db.exec(`
      CREATE TABLE orders (
        id INTEGER PRIMARY KEY,
        customer_id INTEGER NOT NULL,
        order_date TEXT NOT NULL,
        total_amount REAL NOT NULL,
        status TEXT NOT NULL,
        FOREIGN KEY (customer_id) REFERENCES customers (id)
      )
    `);
    
    // Create order_items table
    db.exec(`
      CREATE TABLE order_items (
        id INTEGER PRIMARY KEY,
        order_id INTEGER NOT NULL,
        product_id INTEGER NOT NULL,
        quantity INTEGER NOT NULL,
        price REAL NOT NULL,
        FOREIGN KEY (order_id) REFERENCES orders (id),
        FOREIGN KEY (product_id) REFERENCES products (id)
      )
    `);
    
    // Insert sample data
    insertSampleData(db);
  }
  
  return db;
}

// Insert sample data into the database
function insertSampleData(db: Database.Database) {
  // Insert products
  const products = [
    { name: 'Laptop', category: 'Electronics', price: 999.99, inventory: 45 },
    { name: 'Smartphone', category: 'Electronics', price: 699.99, inventory: 120 },
    { name: 'Headphones', category: 'Accessories', price: 149.99, inventory: 75 },
    { name: 'Monitor', category: 'Electronics', price: 349.99, inventory: 30 },
    { name: 'Keyboard', category: 'Accessories', price: 79.99, inventory: 65 }
  ];
  
  const insertProduct = db.prepare(`
    INSERT INTO products (name, category, price, inventory)
    VALUES (?, ?, ?, ?)
  `);
  
  products.forEach(product => {
    insertProduct.run(product.name, product.category, product.price, product.inventory);
  });
  
  // Insert customers
  const customers = [
    { name: 'John Doe', email: 'john.doe@example.com', signup_date: '2024-01-15' },
    { name: 'Jane Smith', email: 'jane.smith@example.com', signup_date: '2024-02-20' },
    { name: 'Michael Brown', email: 'michael.brown@example.com', signup_date: '2024-03-05' }
  ];
  
  const insertCustomer = db.prepare(`
    INSERT INTO customers (name, email, signup_date)
    VALUES (?, ?, ?)
  `);
  
  customers.forEach(customer => {
    insertCustomer.run(customer.name, customer.email, customer.signup_date);
  });
  
  // Insert orders and order items
  const orders = [
    { customer_id: 1, order_date: '2024-04-10', total_amount: 1149.98, status: 'Delivered' },
    { customer_id: 2, order_date: '2024-04-15', total_amount: 699.99, status: 'Shipped' },
    { customer_id: 3, order_date: '2024-04-20', total_amount: 429.98, status: 'Processing' }
  ];
  
  const insertOrder = db.prepare(`
    INSERT INTO orders (customer_id, order_date, total_amount, status)
    VALUES (?, ?, ?, ?)
  `);
  
  orders.forEach(order => {
    const result = insertOrder.run(order.customer_id, order.order_date, order.total_amount, order.status);
    const orderId = result.lastInsertRowid;
    
    if (orderId === 1) {
      // Add items to first order
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 1, 1, 999.99);
      
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 3, 1, 149.99);
    } else if (orderId === 2) {
      // Add items to second order
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 2, 1, 699.99);
    } else if (orderId === 3) {
      // Add items to third order
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 3, 1, 149.99);
      
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 5, 1, 79.99);
      
      db.prepare(`
        INSERT INTO order_items (order_id, product_id, quantity, price)
        VALUES (?, ?, ?, ?)
      `).run(orderId, 4, 1, 349.99);
    }
  });
  
  console.log('Sample data inserted successfully');
}

// Export database functions
export function getDatabaseSchema(): string {
  const db = new Database(DB_PATH);
  const tables = db.prepare(`
    SELECT name FROM sqlite_master 
    WHERE type='table' AND name NOT LIKE 'sqlite_%'
  `).all();
  
  let schema = '';
  
  for (const table of tables) {
    const tableInfo = db.prepare(`PRAGMA table_info(${table.name})`).all();
    schema += `Table: ${table.name}\n`;
    schema += 'Columns:\n';
    
    for (const column of tableInfo) {
      schema += `  - ${column.name} (${column.type})`;
      if (column.pk === 1) schema += ' PRIMARY KEY';
      if (column.notnull === 1) schema += ' NOT NULL';
      schema += '\n';
    }
    
    schema += '\n';
  }
  
  db.close();
  return schema;
}

// Function to execute a SQL query
export function executeQuery(query: string, params?: any[]): any {
  const db = new Database(DB_PATH);
  let result;
  
  try {
    if (query.trim().toLowerCase().startsWith('select')) {
      const stmt = params ? db.prepare(query).bind(...params) : db.prepare(query);
      result = stmt.all();
    } else {
      const stmt = params ? db.prepare(query).bind(...params) : db.prepare(query);
      result = stmt.run();
    }
    return result;
  } catch (error) {
    throw error;
  } finally {
    db.close();
  }
}
                        

Implementing the MCP Server

Now let's create the MCP server implementation. We'll start by implementing the resources that provide information about our database schema.

Create a file named src/resources/schema.ts:


import { Resource } from '@mcp/sdk';
import { getDatabaseSchema } from '../database';

export class SchemaResource extends Resource {
  constructor() {
    super({
      name: 'database_schema',
      description: 'The schema of the database including tables and columns',
      content_type: 'text/plain',
      is_stream: false
    });
  }

  async read(): Promise {
    return getDatabaseSchema();
  }
}
                        

Now, let's implement the database query tool in src/tools/query.ts:


import { Tool, ToolResponse, ToolResponseFormat } from '@mcp/sdk';
import { executeQuery } from '../database';

export class QueryDatabaseTool extends Tool {
  constructor() {
    super({
      name: 'query_database',
      description: 'Execute a SQL query against the database',
      parameters: {
        type: 'object',
        properties: {
          query: {
            type: 'string',
            description: 'The SQL query to execute'
          },
          params: {
            type: 'array',
            description: 'Optional parameters for the query',
            items: {
              type: 'string'
            }
          }
        },
        required: ['query']
      }
    });
  }

  async invoke(params: any): Promise {
    const { query, params: queryParams } = params;
    
    try {
      const result = executeQuery(query, queryParams);
      
      // For SELECT queries, return the results
      if (query.trim().toLowerCase().startsWith('select')) {
        return {
          format: ToolResponseFormat.JSON,
          data: result
        };
      } 
      
      // For other queries (INSERT, UPDATE, DELETE), return success message
      return {
        format: ToolResponseFormat.STRING,
        data: `Query executed successfully. Rows affected: ${result.changes}`
      };
    } catch (error: any) {
      return {
        format: ToolResponseFormat.ERROR,
        error: `Error executing query: ${error.message}`
      };
    }
  }
}
                        

Let's also implement a tool to list the tables in the database in src/tools/list-tables.ts:


import { Tool, ToolResponse, ToolResponseFormat } from '@mcp/sdk';
import { executeQuery } from '../database';

export class ListTablesTool extends Tool {
  constructor() {
    super({
      name: 'list_tables',
      description: 'List all tables in the database',
      parameters: {
        type: 'object',
        properties: {},
        required: []
      }
    });
  }

  async invoke(): Promise {
    try {
      const result = executeQuery(`
        SELECT name FROM sqlite_master 
        WHERE type='table' AND name NOT LIKE 'sqlite_%'
      `);
      
      return {
        format: ToolResponseFormat.JSON,
        data: result.map((row: any) => row.name)
      };
    } catch (error: any) {
      return {
        format: ToolResponseFormat.ERROR,
        error: `Error listing tables: ${error.message}`
      };
    }
  }
}
                        

Now, let's add a tool to get table information in src/tools/table-info.ts:


import { Tool, ToolResponse, ToolResponseFormat } from '@mcp/sdk';
import { executeQuery } from '../database';

export class TableInfoTool extends Tool {
  constructor() {
    super({
      name: 'get_table_info',
      description: 'Get information about a specific table',
      parameters: {
        type: 'object',
        properties: {
          table_name: {
            type: 'string',
            description: 'The name of the table'
          }
        },
        required: ['table_name']
      }
    });
  }

  async invoke(params: any): Promise {
    const { table_name } = params;
    
    try {
      // Validate table exists
      const tableExists = executeQuery(`
        SELECT name FROM sqlite_master 
        WHERE type='table' AND name = ?
      `, [table_name]);
      
      if (tableExists.length === 0) {
        return {
          format: ToolResponseFormat.ERROR,
          error: `Table '${table_name}' does not exist`
        };
      }
      
      // Get table columns info
      const columns = executeQuery(`PRAGMA table_info(${table_name})`);
      
      // Get sample data (limited to 5 rows)
      const sampleData = executeQuery(`SELECT * FROM ${table_name} LIMIT 5`);
      
      return {
        format: ToolResponseFormat.JSON,
        data: {
          table_name,
          columns,
          sample_data: sampleData
        }
      };
    } catch (error: any) {
      return {
        format: ToolResponseFormat.ERROR,
        error: `Error getting table info: ${error.message}`
      };
    }
  }
}
                        

Defining Resources

In addition to the schema resource we've already implemented, you can define additional resources to provide more context to the AI assistant. For example, you might want to create a resource that provides sample queries or documentation about your database schema.

Here's how you could implement a sample queries resource:


import { Resource } from '@mcp/sdk';

export class SampleQueriesResource extends Resource {
  constructor() {
    super({
      name: 'sample_queries',
      description: 'Example SQL queries for the database',
      content_type: 'text/plain',
      is_stream: false
    });
  }

  async read(): Promise {
    return `
# Sample Queries for the E-commerce Database

## Product Queries
- List all products:
  SELECT * FROM products;

## Customer Queries
- Find customers who signed up in 2024:
  SELECT * FROM customers WHERE signup_date LIKE '2024-%';

## Order Queries
- Get total revenue:
  SELECT SUM(total_amount) AS total_revenue FROM orders;

## Analysis Queries
- Find top-selling products:
  SELECT p.name, SUM(oi.quantity) as total_sold
  FROM order_items oi
  JOIN products p ON oi.product_id = p.id
  GROUP BY p.name
  ORDER BY total_sold DESC;
`;
  }
}
                        

Running Your Server

Now let's put everything together and create our main server file. Create src/index.ts:


import { MCPServer } from '@mcp/sdk';
import { initializeDatabase } from './database';
import { SchemaResource } from './resources/schema';
import { QueryDatabaseTool } from './tools/query';
import { ListTablesTool } from './tools/list-tables';
import { TableInfoTool } from './tools/table-info';

async function main() {
  // Initialize the database
  initializeDatabase();
  
  // Create MCP server
  const server = new MCPServer({
    name: 'SQLite Database Server',
    description: 'A simple MCP server that provides access to a SQLite database',
    version: '1.0.0'
  });
  
  // Register resources
  server.registerResource(new SchemaResource());
  
  // Register tools
  server.registerTool(new QueryDatabaseTool());
  server.registerTool(new ListTablesTool());
  server.registerTool(new TableInfoTool());
  
  // Start the server
  await server.start({
    host: 'localhost',
    port: 8000
  });
  
  console.log('MCP Server started on http://localhost:8000');
  console.log('Connect from Claude Desktop using the URL: ws://localhost:8000');
}

main().catch(console.error);
                        

Now let's add a script to our package.json file to run the server:


{
  "scripts": {
    "start": "ts-node src/index.ts",
    "build": "tsc",
    "serve": "node dist/index.js"
  }
}
                        

To run your server, execute the following command:


npm start
                        

Connecting to Claude Desktop

Now that your server is running, you can connect it to Claude Desktop:

  1. Open Claude Desktop application
  2. Click on the settings icon
  3. Select "Connections"
  4. Click "Add Connection"
  5. Enter the server address: ws://localhost:8000
  6. Click "Connect"

Once connected, you can start a conversation with Claude and ask it to interact with your database:

  • "What tables are in the database?"
  • "Show me the schema of the customers table."
  • "How many products are in the Electronics category?"
  • "What's the total value of all products in inventory?"
  • "Show me all orders with a total amount over $700."

Claude will use your MCP server to execute the appropriate queries and return the results.

Streaming Large Results

For large query results, you can implement streaming to handle the data more efficiently:


export class StreamingQueryTool extends Tool {
  constructor() {
    super({
      name: 'streaming_query',
      description: 'Execute a SQL query with streaming results',
      parameters: {
        // Same as QueryDatabaseTool
      }
    });
  }

  async *stream(params: any): AsyncGenerator {
    const { query } = params;
    
    // Implementation using streams for large results
    // ...
  }
}
                        

Conclusion

In this guide, we've walked through building a complete MCP server in TypeScript that connects to a SQLite database. This same approach can be extended to work with other databases like PostgreSQL, MySQL, or MongoDB by changing the database adapter.

The Model Context Protocol enables powerful integrations between AI models and your data while maintaining full control over access and permissions. By building custom MCP servers, you can create specialized tools that allow AI assistants like Claude to work with your specific use cases.

Resources