The Model Context Protocol (MCP) is an open standard that lets AI assistants call tools and read data from outside the model. Claude, Claude Code, Cursor, VS Code and many other clients support it, so one MCP server you write works with all of them. In this tutorial we build a small server in Node.js that looks up GitHub repositories.
How MCP works
An MCP server exposes three kinds of things to a client:
- Tools: functions the model can call, such as “search issues” or “create a ticket”.
- Resources: read-only data the client can load into context, such as files or database rows.
- Prompts: reusable prompt templates.
The client and server talk JSON-RPC over a transport. Local servers usually use stdio: the client starts your server as a child process and exchanges messages through stdin and stdout. Remote servers use Streamable HTTP. We will start with stdio.
Set up the project
mkdir github-mcp && cd github-mcp
npm init -y
npm pkg set type=module
npm install @modelcontextprotocol/sdk zod
zod describes each tool’s input, and the SDK turns that into the JSON Schema the client sees.
Write the server
Create server.js:
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { z } from 'zod';
const server = new McpServer({ name: 'github-repos', version: '1.0.0' });
server.registerTool(
'get_repo',
{
title: 'Get GitHub repository',
description: 'Get stars, forks, open issues and the description of a public GitHub repository.',
inputSchema: {
owner: z.string().describe('Repository owner, e.g. vercel'),
repo: z.string().describe('Repository name, e.g. next.js'),
},
},
async ({ owner, repo }) => {
const res = await fetch(`https://api.github.com/repos/${owner}/${repo}`);
if (!res.ok) {
return {
isError: true,
content: [{ type: 'text', text: `GitHub returned ${res.status} for ${owner}/${repo}` }],
};
}
const data = await res.json();
const summary = {
name: data.full_name,
description: data.description,
stars: data.stargazers_count,
forks: data.forks_count,
openIssues: data.open_issues_count,
language: data.language,
updated: data.pushed_at,
};
return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }] };
}
);
await server.connect(new StdioServerTransport());
console.error('github-repos MCP server running on stdio');
A few details matter here:
- The description is your prompt. The model decides when to call a tool by reading its description and parameter descriptions, so make them specific.
- Return errors, do not throw them. Setting
isError: truelets the model see what went wrong and try again with different input. - Never write to stdout. stdout carries the protocol, so a stray
console.logcorrupts the messages. Log withconsole.errorinstead.
Test it with the MCP Inspector
npx @modelcontextprotocol/inspector node server.js
This opens a web UI. Click Connect, open the Tools tab, pick get_repo, enter an owner and repo, and run it. You see exactly what the model would receive, without involving an AI client yet.
Connect it to Claude Code
claude mcp add github-repos -- node /absolute/path/to/github-mcp/server.js
Start a new Claude Code session and ask something like “How many stars does vercel/next.js have?”. Claude calls get_repo and answers from the result. Other clients, such as Claude Desktop or Cursor, take the same command and arguments in their MCP settings.
Add a second tool
Each tool is one more registerTool call. Here is one that lists a repository’s latest open issues:
server.registerTool(
'list_issues',
{
title: 'List open issues',
description: 'List the most recent open issues of a public GitHub repository.',
inputSchema: {
owner: z.string(),
repo: z.string(),
limit: z.number().int().min(1).max(20).default(5),
},
},
async ({ owner, repo, limit }) => {
const res = await fetch(
`https://api.github.com/repos/${owner}/${repo}/issues?state=open&per_page=${limit}`
);
if (!res.ok) {
return { isError: true, content: [{ type: 'text', text: `GitHub returned ${res.status}` }] };
}
const issues = (await res.json()).map((i) => `#${i.number} ${i.title}`);
return { content: [{ type: 'text', text: issues.join('\n') || 'No open issues.' }] };
}
);
Going further
- Authentication: read a token from an environment variable (for example
process.env.GITHUB_TOKEN) and pass it withclaude mcp add --env GITHUB_TOKEN=.... Never hard-code secrets. - Remote servers: swap the stdio transport for the SDK’s Streamable HTTP transport to host the server for a team.
- Fewer, better tools: a handful of well-described tools works better than dozens of thin API wrappers, because the model has fewer choices to get wrong.
That is all an MCP server needs: a name, some tools with clear descriptions, and a transport. Once you have built one, connecting your own APIs, databases and scripts to an AI assistant takes minutes.




Comments