How to Build a Custom MCP Server in TypeScript

Initialize a project, set up the MCP server instance, connect the transport layer, and pass API keys. The core skeleton every server needs.

7 min read
build MCP server TypeScript custom MCP server MCP server tutorial MCP TypeScript

The barrier to building your own MCP server is lower than you think

Most operators hear "build a server" and assume they need a software engineering background. They don't. An MCP server is a structured file that tells AI tools how to connect to an outside service. If you can follow a recipe, you can build one. The real skill is knowing which connections are worth making for your workflow.

We're going to walk through building an MCP server in TypeScript from an empty folder to a working, compilable server skeleton. The example uses Linear (a task management app), but every principle here applies to any API you want to connect: Figma, your CRM, a project tracker, whatever fits your client engagements.

By the end of this guide you'll have the core server running. Future steps like adding tools, resources, and prompts build on top of this same foundation.

Set up the project from scratch

Every MCP server starts the same way. You create a directory, initialize a Node.js project, and install three key packages. Here's the full sequence.

Step 1: Create your project folder and initialize it.

mkdir linear-server && cd linear-server
npm init -y

Step 2: Install your dependencies.

You need the MCP SDK (the protocol library), Zod (for input validation), and any service-specific SDK you plan to connect. For Linear, that means the @linear/sdk package. TypeScript and Node type definitions go in as dev dependencies.

npm install @modelcontextprotocol/sdk zod @linear/sdk
npm install -D typescript @types/node

Step 3: Create your file structure.

mkdir src
touch src/index.ts tsconfig.json .gitignore .env

Step 4: Configure the essentials.

Your package.json needs two changes. Set the module type and add a build script that compiles TypeScript to JavaScript.

{
  "type": "module",
  "scripts": {
    "build": "tsc && chmod 755 build/index.js"
  }
}

Your tsconfig.json tells TypeScript how to compile. This is the same config used in the official MCP quick-start docs.

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

Add node_modules, build, and .env to your .gitignore. That .env file is where you'll store your Linear API key locally, but the server itself won't read from it directly. We'll cover why in a moment.

> Operator tip. If you're building a server for a different service, swap out @linear/sdk for whatever SDK your target API provides. The MCP SDK and Zod stay the same regardless of what you're connecting to. That's the pattern: MCP SDK + Zod + your service SDK.

Write the core server file

Open src/index.ts. This is where everything lives. The file has four responsibilities: grab the API key, initialize the service client, initialize the MCP server, and connect the transport layer.

Handling the API key. Rather than reading from a .env file at runtime, we pass the API key as a command-line argument. This is the same approach used when registering MCP servers in tools like Cursor or Claude Code. You specify node build/index.js YOURAPIKEY and the server picks it up from there.

const args = process.argv.slice(2);
const linearApiKey = args[0];

if (!linearApiKey) {
  console.error("Linear API key is required as a command-line argument");
  process.exit(1);
}

The process.argv array contains everything passed on the command line. Index 0 is the Node binary, index 1 is your script path, and index 2 onward is your actual arguments. Slicing at 2 gives you only the arguments you care about.

Initialize the service client. With the API key in hand, create a Linear client. This object gives you access to all of Linear's API methods: creating tasks, listing teams, fetching projects.

import { LinearClient } from "@linear/sdk";

const linearClient = new LinearClient({
  apiKey: linearApiKey,
});

Initialize the MCP server. This is the same pattern for every MCP server you'll ever build. You create a new McpServer instance with a name and version number.

import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";

const server = new McpServer({
  name: "linear",
  version: "1.0.0",
});

Between this point and the transport connection is where you'd register your tools, resources, and prompts. We're skipping those for this guide and focusing on the skeleton.

Connect the transport layer. The transport is how the MCP server communicates with whatever AI tool calls it. Standard input/output (stdio) is the default and works with every MCP-compatible client.

import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";

async function main() {
  console.log("Starting Linear MCP server...");
  const transport = new StdioServerTransport();
  await server.connect(transport);
  console.log("Linear MCP server running");
}

main().catch((error) => {
  console.error("Server error:", error);
  process.exit(1);
});

That main() function at the bottom invokes the entire server. The catch block handles any startup failures gracefully.

> Why stdio and not HTTP? MCP servers registered in tools like Cursor and Claude Code communicate through standard input/output, not over a network. The AI tool launches your server as a subprocess and pipes data back and forth. No ports to configure, no firewall rules, no CORS. It runs locally on your machine.

Compile, verify, and register

With the core file written, compile it and confirm there are no errors.

npm run build

If the build succeeds with no error messages, your build/ directory now contains index.js, the compiled JavaScript version of your server. If you see TypeScript errors, they'll point you to the exact line and issue.

Registering the server in your AI tool. The compiled server needs to be pointed at by whatever MCP-compatible client you're using. In Cursor, you'd go to Settings, find the MCP section, and add an entry like this:

{
  "mcpServers": {
    "linear": {
      "command": "node",
      "args": [
        "/full/path/to/linear-server/build/index.js",
        "your-linear-api-key-here"
      ]
    }
  }
}

Notice the second item in the args array. That's your API key being passed as the command-line argument that process.argv.slice(2) picks up inside your server code.

Keep your API key out of version control. Store the actual key value in your .env file for reference, but the server reads it from the command-line argument in your MCP client configuration. If you're sharing this server with teammates, each person supplies their own key in their own local config. The server code never contains the key itself.

ComponentWhat it does
@modelcontextprotocol/sdkThe protocol library that handles MCP communication
zodValidates input schemas for your tools and resources
@linear/sdkService-specific client for calling the Linear API
StdioServerTransportPipes data between the AI tool and your server
McpServerThe server instance that holds your tools, resources, and prompts
process.argvCaptures the API key from the command line at startup

> What you've built so far. A working MCP server skeleton that authenticates with Linear, initializes the protocol layer, and connects via stdio transport. It doesn't do anything yet because we haven't registered tools or resources. But the foundation is solid. Every tool you add from here follows the same pattern: server.tool() with a name, description, input schema, and handler function. The architecture doesn't change, it only grows.

Where to go from here

The skeleton you've built is the same starting point for any MCP server, regardless of which service you're connecting. The three components you'll add next are tools (actions the AI can take, like creating a task), resources (data the AI can read, like a team list), and prompts (templates that structure how the AI uses your tools).

Feed the docs to an LLM for faster development. The MCP documentation site publishes an llms-full.txt file, which is the entire docs in plain text. Copy that file plus the TypeScript SDK readme from GitHub, paste both into Claude or ChatGPT, and describe the server you want to build. The LLM can generate working tool and resource implementations because you've given it the complete reference material. This trick works well for any service with a documented API.

Adapt this to your own use case. Swap Linear for whatever service matters to your client engagements. A Figma MCP server could let you query designs directly from an AI coding tool. A CRM server could pull client records into your workflow without switching tabs. The setup process is identical: install the service SDK, create the client, and register tools that call the API methods you need.

The pattern stays the same. What changes is the service client and the specific tools you register. That's the real takeaway here: once you've built one MCP server, the second one takes a fraction of the time.

Keep Going

Ready to Start Building?

Pick the next step that matches where you are right now.

Tutorial
Claude Code Basics

Start with the terminal basics. A hands-on, step-by-step guide to your first 10 minutes with Claude Code.

Start the Tutorial
Guide
AI-Powered Workflows

Automate your client work. Learn how to connect AI tools into workflows that handle repetitive tasks for you.

Read the Guide
Community
Join the Community

Connect with other fractional leaders building with AI. Share workflows, get feedback, and learn from operators who are ahead of you.

Apply to Join