Building the Core of a Custom MCP Server
Set up the server skeleton, initialize the transport layer, and handle API key injection. Every MCP server you build starts from this TypeScript foundation.
--- title: Building the Core of a Custom MCP Server description: Set up the server skeleton, initialize the transport layer, and handle API key injection. Every MCP server you build starts from this foundation. author: FractionalSkill ---
Building the Core of a Custom MCP Server
Every custom MCP server has the same skeleton underneath it. A server instance. A transport layer. A main function that ties them together. The tools, resources, and prompts you add later are what differentiate servers -- but without this core, none of them run.
This guide walks through building that foundation using a Linear API integration as the working example. The same pattern applies to any service you want to connect.
Setting up the project
Start by creating a new directory for your server and initializing it as a Node.js project.
mkdir linear-server
cd linear-server
npm init -y
Then install the dependencies your server needs:
npm install @modelcontextprotocol/sdk zod @linear/sdk
npm install --save-dev @types/node typescript
Here is what each package does:
| Package | Purpose |
|---|---|
@modelcontextprotocol/sdk | The MCP SDK -- provides the server class and transport utilities |
zod | Schema validation for tool parameters |
@linear/sdk | The Linear API client library |
@types/node + typescript | TypeScript compilation support |
Create a src/ directory and an index.ts file inside it. This is where your server code lives. Also create a tsconfig.json at the root level to configure TypeScript compilation.
{
"compilerOptions": {
"target": "ES2022",
"module": "Node16",
"moduleResolution": "Node16",
"outDir": "./build",
"rootDir": "./src",
"strict": true
}
}
Add a build script to your package.json:
{
"type": "module",
"scripts": {
"build": "tsc && chmod +x build/index.js"
}
}
Run npm run build once to confirm there are no errors before writing any server code. An empty src/index.ts should compile cleanly.
Handling API keys from the command line
When Claude Desktop or Cursor launches your MCP server, it runs a command. That command can include arguments -- which is how you pass credentials to the server at startup without hardcoding them.
Add this to the top of src/index.ts:
const args = process.argv.slice(2);
const linearApiKey = args[0];
if (!linearApiKey) {
console.error('Error: LINEAR_API_KEY argument is required');
process.exit(1);
}
process.argv captures everything passed to the Node process when it starts. Index 0 is the Node binary path. Index 1 is the script path. Index 2 onward is your actual arguments. Slicing at 2 gives you the actual values you passed.
> Keep API keys out of your code. Never commit credentials to version control. Passing them as command-line arguments -- injected by your MCP client config -- keeps them in one place and out of your codebase. Add .env to your .gitignore as a habit even if you are not using dotenv directly.
Initializing the Linear client and MCP server
With your API key available, initialize both the service client and the MCP server instance:
import { LinearClient } from '@linear/sdk';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
const linearClient = new LinearClient({ apiKey: linearApiKey });
const server = new McpServer({
name: 'linear',
version: '1.0.0',
});
The LinearClient gives you access to all of Linear's API helpers -- you'll call methods on this object when implementing tools. The McpServer instance is the container that holds all your tools, resources, and prompts.
The name and version fields identify your server to the client. Use something descriptive. When you have multiple servers configured, these labels show up in Claude Desktop's tool list.
Connecting the transport layer
The transport layer is what allows the MCP client to communicate with your server. For servers launched by Claude Desktop or Cursor, you use standard input/output transport. The client sends messages to your server's stdin and reads responses from stdout.
Add the main function at the bottom of your file:
async function main() {
console.error('Starting Linear MCP server...');
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('Linear MCP server running.');
}
main().catch((error) => {
console.error('Fatal error:', error);
process.exit(1);
});
A few things worth noting here. Log to console.error, not console.log. Since the server communicates over stdout, any console.log output gets mixed into the message stream and breaks the protocol. Stderr is safe for diagnostic output.
The server.connect(transport) call is what starts the server listening for requests. Everything you register on server before this call -- tools, resources, prompts -- becomes available to the client.
Running the build and wiring to Claude Desktop
Run npm run build again. This time your server code compiles. You should see a build/ directory with index.js inside.
To connect this server to Claude Desktop, open your claudedesktopconfig.json and add:
{
"mcpServers": {
"linear": {
"command": "node",
"args": [
"/absolute/path/to/linear-server/build/index.js",
"your-linear-api-key-here"
]
}
}
}
Replace the path with the actual absolute path to your compiled file. Replace the API key with your Linear key from Linear's Settings > Security & Access > API Keys.
Restart Claude Desktop. If the server initializes without errors, you will see it in the developer tools panel. The tool list will be empty until you register tools in the next step -- but the connection itself is live.
> Use absolute paths in your config. Claude Desktop launches servers from a different working directory than your terminal. Relative paths break silently. The absolute path to your build/index.js is the only reliable approach here.
What you have at this point
This skeleton compiles, connects, and runs. It handles API key injection cleanly, initializes both a service client and an MCP server instance, and establishes the transport layer the protocol requires.
Every MCP server you build follows this same pattern. The service changes -- Slack instead of Linear, a database instead of an API -- but the structure stays the same. Initialize your client, initialize your server, connect the transport, register your tools. Build once, repeat the pattern.
The next step is adding tools to this skeleton so the server can actually do something useful.