Ship a real, production-ready MCP server from a blank directory — combining every Tool, Resource, and Prompt technique you learned this week.
mkdir devdash-mcp && cd devdash-mcp
npm init -y
npm install @modelcontextprotocol/sdk zod
npm install -D typescript tsx @types/node
npx tsc --init --target ES2022 --module NodeNext \
--moduleResolution NodeNext --strict true \
--outDir dist --rootDir srcbash
package.json so you can run the server with a single command.{
"scripts": {
"start": "node dist/index.js",
"dev": "tsx watch src/index.ts",
"build": "tsc"
},
"type": "module"
}package.json
// src/github.ts
const GITHUB_TOKEN = process.env.GITHUB_TOKEN ?? '';
const BASE = 'https://api.github.com';
const TTL = 5 * 60 * 1000; // 5 minutes
interface CacheEntry<T> { value: T; expiresAt: number }
const cache = new Map<string, CacheEntry<unknown>>();
async function ghFetch(path: string, params?: Record<string, string>): Promise<unknown> {
const url = new URL(BASE + path);
if (params) Object.entries(params).forEach(([k, v]) => url.searchParams.set(k, v));
const key = url.toString();
const hit = cache.get(key);
if (hit && Date.now() < hit.expiresAt) return hit.value;
let delay = 400;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch(url.toString(), {
headers: {
Authorization: `Bearer ${GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28',
},
});
if (res.status === 429 || res.status >= 500) {
await new Promise(r => setTimeout(r, delay));
delay *= 2;
continue;
}
if (!res.ok) throw new Error(`GitHub ${res.status}: ${await res.text()}`);
const data = await res.json();
cache.set(key, { value: data, expiresAt: Date.now() + TTL });
return data;
}
throw new Error('GitHub API unavailable after 3 retries');
}
export async function searchRepos(query: string, perPage = 10, page = 1) {
return ghFetch('/search/repositories', { q: query, per_page: String(perPage), page: String(page) }) as Promise<{
total_count: number;
items: Array<{ full_name: string; description: string | null; stargazers_count: number; language: string | null; html_url: string }>;
}>;
}
export async function getRepoMeta(owner: string, repo: string) {
return ghFetch(`/repos/${owner}/${repo}`) as Promise<Record<string, unknown>>;
}
export async function getFileContent(owner: string, repo: string, path: string, ref = 'HEAD') {
const data = await ghFetch(`/repos/${owner}/${repo}/contents/${path}`, { ref }) as {
content: string; encoding: string; name: string; size: number;
};
if (data.encoding !== 'base64') throw new Error('Unexpected encoding: ' + data.encoding);
return Buffer.from(data.content, 'base64').toString('utf-8');
}
export async function getRateLimits() {
return ghFetch('/rate_limit') as Promise<{ resources: Record<string, { limit: number; remaining: number; reset: number }> }>;
}src/github.ts
.env file with GITHUB_TOKEN=ghp_.... Load it by prepending env $(cat .env | xargs) to your run command, or use a package like dotenv. Add .env to .gitignore.// src/complexity.ts
/**
* Approximate cyclomatic complexity via keyword counting.
* CC = 1 + number of decision points (if/else if/for/while/case/catch/&&/||/?)
*
* Rule of thumb: CC 1-10 = low, 11-20 = moderate, 21+ = high risk.
*/
export function analyseComplexity(code: string): {
score: number;
rating: 'low' | 'moderate' | 'high';
breakdown: Record<string, number>;
lines: number;
suggestions: string[];
} {
const patterns: Record<string, RegExp> = {
if: /\bif\s*\(/g,
'else if': /\belse\s+if\s*\(/g,
for: /\bfor\s*\(/g,
while: /\bwhile\s*\(/g,
case: /\bcase\s+.+:/g,
catch: /\bcatch\s*\(/g,
and: /&&/g,
or: /\|\|/g,
ternary: /\?(?!\.)/g, // ? but not optional chaining
};
const breakdown: Record<string, number> = {};
let total = 1; // base complexity = 1
for (const [name, regex] of Object.entries(patterns)) {
const count = (code.match(regex) ?? []).length;
if (count > 0) breakdown[name] = count;
total += count;
}
const rating: 'low' | 'moderate' | 'high' =
total <= 10 ? 'low' : total <= 20 ? 'moderate' : 'high';
const suggestions: string[] = [];
if (total > 10) suggestions.push('Consider extracting nested conditions into named predicates.');
if ((breakdown.for ?? 0) + (breakdown.while ?? 0) > 3)
suggestions.push('Multiple loops detected — consider array higher-order methods (map/filter/reduce).');
if ((breakdown.case ?? 0) > 5)
suggestions.push('Large switch — consider a lookup table or strategy pattern.');
if ((breakdown.and ?? 0) + (breakdown.or ?? 0) > 4)
suggestions.push('Complex boolean expressions — extract into well-named helper functions.');
return { score: total, rating, breakdown, lines: code.split('\n').length, suggestions };
}src/complexity.ts
isError: true error handling. Notice how create_pr_draft uses sampling to make the server "think" on your behalf.// src/tools.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
import { searchRepos, getFileContent, getRateLimits } from './github.js';
import { analyseComplexity } from './complexity.js';
export function registerTools(server: McpServer): void {
// ── Tool 1: search_repos ─────────────────────────────────────────────────
server.tool(
'search_repos',
'Search GitHub repositories. Returns up to 10 results with a pagination cursor.',
{
query: z.string().min(1).describe('GitHub search query (same syntax as github.com/search)'),
cursor: z.number().int().min(1).default(1).describe('Page number for pagination (starts at 1)'),
},
async ({ query, cursor }) => {
try {
const result = await searchRepos(query, 10, cursor);
const items = result.items.map(r =>
`• ${r.full_name} ⭐${r.stargazers_count} [${r.language ?? 'unknown'}]\n ${r.description ?? 'No description'}\n ${r.html_url}`
);
const nextCursor = result.items.length === 10 ? cursor + 1 : null;
return {
content: [{
type: 'text' as const,
text: [
`Found ${result.total_count.toLocaleString()} repos. Showing page ${cursor}.`,
...items,
nextCursor ? `\nNext page cursor: ${nextCursor}` : '\nNo more pages.',
].join('\n'),
}],
};
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: `search_repos failed: ${String(err)}` }] };
}
}
);
// ── Tool 2: get_file_content ──────────────────────────────────────────────
server.tool(
'get_file_content',
'Fetch the raw text content of a file from a GitHub repository.',
{
owner: z.string().describe('Repository owner (user or org)'),
repo: z.string().describe('Repository name'),
path: z.string().describe('File path within the repo (e.g., src/index.ts)'),
ref: z.string().default('HEAD').describe('Branch, tag, or commit SHA'),
},
async ({ owner, repo, path, ref }) => {
try {
const content = await getFileContent(owner, repo, path, ref);
return {
content: [
{ type: 'text' as const, text: `File: ${owner}/${repo}/${path} @ ${ref}` },
{ type: 'text' as const, text: content },
],
};
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: `get_file_content failed: ${String(err)}` }] };
}
}
);
// ── Tool 3: analyze_complexity ────────────────────────────────────────────
server.tool(
'analyze_complexity',
'Compute approximate cyclomatic complexity for a code snippet and receive actionable suggestions.',
{
code: z.string().min(10).describe('Source code to analyse (any language)'),
language: z.string().optional().describe('Programming language hint for context'),
},
async ({ code, language }) => {
const result = analyseComplexity(code);
const ratingEmoji = { low: '🟢', moderate: '🟡', high: '🔴' }[result.rating];
const lines = [
`${ratingEmoji} Cyclomatic Complexity: ${result.score} (${result.rating})`,
`Lines analysed: ${result.lines}${language ? ` | Language: ${language}` : ''}`,
'',
'Breakdown:',
...Object.entries(result.breakdown).map(([k, v]) => ` ${k}: ${v}`),
];
if (result.suggestions.length) {
lines.push('', 'Suggestions:');
result.suggestions.forEach(s => lines.push(` • ${s}`));
}
return { content: [{ type: 'text' as const, text: lines.join('\n') }] };
}
);
// ── Tool 4: create_pr_draft ───────────────────────────────────────────────
server.tool(
'create_pr_draft',
'Generate a pull-request title and description by sampling the AI model with a structured prompt.',
{
diff_summary: z.string().describe('A plain-English summary of the diff or changes made'),
base_branch: z.string().default('main').describe('Target branch for the PR'),
ticket_id: z.string().optional().describe('Jira / Linear ticket ID to include in the title'),
},
{ readOnlyHint: false, destructiveHint: false },
async ({ diff_summary, base_branch, ticket_id }, { server: srv }) => {
try {
const prefix = ticket_id ? `[${ticket_id}] ` : '';
const response = await srv.createMessage({
messages: [{
role: 'user',
content: {
type: 'text',
text: [
'Write a concise GitHub pull-request title and description for the following change.',
`Target branch: ${base_branch}`,
ticket_id ? `Ticket: ${ticket_id}` : '',
'',
'Changes summary:',
diff_summary,
'',
'Format your response as:',
'TITLE: <one-line title, max 72 chars>',
'BODY:',
'<markdown body with ## Summary, ## Changes, ## Testing sections>',
].filter(Boolean).join('\n'),
},
}],
modelPreferences: { speedPriority: 0.3, intelligencePriority: 0.8 },
maxTokens: 600,
includeContext: 'none',
});
const text = response.content.type === 'text' ? response.content.text : '';
const titleMatch = text.match(/^TITLE:\s*(.+)$/m);
const bodyMatch = text.match(/BODY:\s*([\s\S]+)$/m);
return {
content: [{
type: 'text' as const,
text: [
`PR Title: ${prefix}${titleMatch?.[1]?.trim() ?? 'Update'}`,
'',
bodyMatch?.[1]?.trim() ?? text,
].join('\n'),
}],
};
} catch (err) {
return { isError: true, content: [{ type: 'text' as const, text: `create_pr_draft failed: ${String(err)}` }] };
}
}
);
}src/tools.ts
create_pr_draft to work the host (Claude Desktop) must support sampling. Claude Desktop does — it shows a confirmation dialog before the model response is returned to your server.// src/resources.ts
import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
import { getRepoMeta, getFileContent, getRateLimits } from './github.js';
const SERVER_START = new Date().toISOString();
export function registerResources(server: McpServer): void {
// ── Static 1: Server status ───────────────────────────────────────────────
server.resource(
'devdash-status',
'devdash://status',
{ mimeType: 'application/json' },
async () => ({
contents: [{
uri: 'devdash://status',
mimeType: 'application/json',
text: JSON.stringify({
server: 'DevDash MCP',
version: '1.0.0',
status: 'healthy',
startedAt: SERVER_START,
uptime: Math.floor((Date.now() - new Date(SERVER_START).getTime()) / 1000) + 's',
}, null, 2),
}],
})
);
// ── Static 2: Server config ───────────────────────────────────────────────
server.resource(
'devdash-config',
'devdash://config',
{ mimeType: 'application/json' },
async () => ({
contents: [{
uri: 'devdash://config',
mimeType: 'application/json',
text: JSON.stringify({
cacheTtlMs: 5 * 60 * 1000,
maxRetries: 3,
retryBaseDelayMs: 400,
githubApiBase: 'https://api.github.com',
defaultPerPage: 10,
samplingEnabled: true,
}, null, 2),
}],
})
);
// ── Static 3: GitHub rate limits ──────────────────────────────────────────
server.resource(
'devdash-limits',
'devdash://limits',
{ mimeType: 'application/json' },
async () => {
const data = await getRateLimits();
return {
contents: [{
uri: 'devdash://limits',
mimeType: 'application/json',
text: JSON.stringify(data, null, 2),
}],
};
}
);
// ── Template 1: Repo metadata ─────────────────────────────────────────────
server.resource(
'github-repo',
new ResourceTemplate('github://repo/{owner}/{repo}', { list: undefined }),
{ mimeType: 'application/json' },
async (uri, { owner, repo }) => {
const meta = await getRepoMeta(String(owner), String(repo));
return {
contents: [{
uri: uri.href,
mimeType: 'application/json',
text: JSON.stringify(meta, null, 2),
}],
};
}
);
// ── Template 2: File content ──────────────────────────────────────────────
server.resource(
'github-file',
new ResourceTemplate('github://file/{owner}/{repo}/{+path}', { list: undefined }),
{ mimeType: 'text/plain' },
async (uri, { owner, repo, path }) => {
const content = await getFileContent(String(owner), String(repo), String(path));
return {
contents: [{
uri: uri.href,
mimeType: 'text/plain',
text: content,
}],
};
}
);
}src/resources.ts
{+path} is a level-2 RFC 6570 path expression — it allows slashes in the variable, so github://file/owner/repo/src/utils/helper.ts maps correctly. Plain {path} would only allow a single path segment.code-review prompt embeds a live GitHub resource so Claude reads the real file — no copy-pasting required.// src/prompts.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { z } from 'zod';
export function registerPrompts(server: McpServer): void {
// ── Prompt 1: code-review ─────────────────────────────────────────────────
server.prompt(
'code-review',
'In-depth code review for a GitHub file with severity-rated findings.',
{
owner: z.string().describe('Repo owner'),
repo: z.string().describe('Repo name'),
path: z.string().describe('File path to review (e.g., src/index.ts)'),
focus: z.enum(['security', 'performance', 'readability', 'all']).default('all'),
},
({ owner, repo, path, focus }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'resource' as const,
resource: {
uri: `github://file/${owner}/${repo}/${path}`,
mimeType: 'text/plain',
text: `[Content of ${owner}/${repo}/${path} will be resolved by the MCP client]`,
},
},
},
{
role: 'user' as const,
content: {
type: 'text' as const,
text: [
`Please review the file \`${path}\` from \`${owner}/${repo}\`.`,
focus !== 'all' ? `Focus specifically on **${focus}** concerns.` : 'Cover security, performance, and readability.',
'',
'Structure your review as:',
'## 🔴 Critical Issues',
'## 🟡 Warnings',
'## 🟢 Suggestions',
'## ✅ What\'s Done Well',
'',
'For each issue cite the line range and provide a concrete fix.',
].join('\n'),
},
},
],
})
);
// ── Prompt 2: write-pr-description ───────────────────────────────────────
server.prompt(
'write-pr-description',
'Generate a structured GitHub pull-request description from a diff summary.',
{
summary: z.string().describe('Plain-English description of changes'),
base_branch: z.string().default('main').describe('Target branch'),
ticket: z.string().optional().describe('Linked ticket ID'),
breaking: z.enum(['yes', 'no']).default('no').describe('Does this PR include breaking changes?'),
},
({ summary, base_branch, ticket, breaking }) => ({
messages: [{
role: 'user' as const,
content: {
type: 'text' as const,
text: [
'Create a complete GitHub pull-request description in Markdown.',
`Merging into: \`${base_branch}\``,
ticket ? `Linked ticket: ${ticket}` : '',
breaking === 'yes' ? '⚠️ This PR contains BREAKING CHANGES.' : '',
'',
'Changes summary:',
summary,
'',
'Include these sections: ## Summary, ## Motivation, ## Changes, ## Screenshots (if applicable), ## Testing, ## Checklist.',
'End with a checklist using GitHub markdown checkboxes (- [ ] item).',
].filter(Boolean).join('\n'),
},
}],
})
);
// ── Prompt 3: debug-assist ────────────────────────────────────────────────
server.prompt(
'debug-assist',
'Contextual debugging session for an error with optional stack trace.',
{
error: z.string().describe('Error message or exception text'),
stack: z.string().optional().describe('Stack trace (optional)'),
language: z.string().default('TypeScript').describe('Programming language'),
context: z.string().optional().describe('Brief description of what the code is doing'),
},
({ error, stack, language, context }) => ({
messages: [
{
role: 'user' as const,
content: {
type: 'text' as const,
text: [
`I'm debugging a ${language} error. Please help me diagnose and fix it.`,
context ? `Context: ${context}` : '',
'',
`**Error:** \`\`\`\n${error}\n\`\`\``,
stack ? `**Stack trace:**\n\`\`\`\n${stack}\n\`\`\`` : '',
'',
'Please provide:',
'1. **Root cause** — what is actually going wrong',
'2. **Why it happens** — the mechanism',
'3. **Fix** — exact code change(s)',
'4. **Prevention** — how to avoid this class of bug in future',
].filter(Boolean).join('\n'),
},
},
],
})
);
}src/prompts.ts
index.ts file wires everything together: declare capabilities, register all three primitive families, connect the transport, and start listening.// src/index.ts
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import { registerTools } from './tools.js';
import { registerResources } from './resources.js';
import { registerPrompts } from './prompts.js';
const server = new McpServer(
{
name: 'devdash-mcp',
version: '1.0.0',
},
{
capabilities: {
tools: { listChanged: false },
resources: { subscribe: false, listChanged: false },
prompts: { listChanged: false },
sampling: {}, // required for create_pr_draft
logging: {},
},
}
);
// Register all primitives
registerTools(server);
registerResources(server);
registerPrompts(server);
// Connect and start
const transport = new StdioServerTransport();
await server.connect(transport);
console.error('[DevDash MCP] Server started — listening on stdio');src/index.ts
console.error) or you will corrupt the protocol stream.npm run build
# Outputs to dist/ (dist/index.js, dist/tools.js, …)bash
| OS | Path |
|---|---|
| macOS | ~/Library/Application Support/Claude/claude_desktop_config.json |
| Windows | %APPDATA%\Claude\claude_desktop_config.json |
| Linux | ~/.config/Claude/claude_desktop_config.json |
{
"mcpServers": {
"devdash": {
"command": "node",
"args": ["/absolute/path/to/devdash-mcp/dist/index.js"],
"env": {
"GITHUB_TOKEN": "ghp_YOUR_TOKEN_HERE"
}
}
}
}claude_desktop_config.json
npx @modelcontextprotocol/inspector to introspect capabilities without a full host.# Test search_repos
"Search GitHub for MCP TypeScript servers with more than 100 stars"
# Test get_file_content
"Fetch the README from modelcontextprotocol/typescript-sdk"
# Test analyze_complexity + get_file_content together
"Fetch src/index.ts from modelcontextprotocol/typescript-sdk
and analyse its cyclomatic complexity"
# Test code-review prompt
"Use the code-review prompt on modelcontextprotocol/typescript-sdk,
file src/server/mcp.ts, focus on readability"
# Test the devdash://status resource
"Read the devdash://status resource and tell me how long the server has been running"
# Test create_pr_draft (sampling)
"Draft a PR for: Added pagination cursor to the search_repos tool
so results beyond the first 10 can be fetched"smoke tests
// tests/complexity.test.ts
import { describe, it, expect } from 'vitest';
import { analyseComplexity } from '../src/complexity.js';
describe('analyseComplexity', () => {
it('returns score 1 for a trivial function', () => {
const code = 'function hello() { return "world"; }';
const { score, rating } = analyseComplexity(code);
expect(score).toBe(1);
expect(rating).toBe('low');
});
it('counts if and else-if separately', () => {
const code = `
function classify(n) {
if (n < 0) return 'negative';
else if (n === 0) return 'zero';
else return 'positive';
}
`;
const { score, breakdown } = analyseComplexity(code);
expect(breakdown['if']).toBe(1);
expect(breakdown['else if']).toBe(1);
expect(score).toBe(3);
});
it('rates score 21+ as high', () => {
// 25 if-statements = score 26
const code = Array.from({ length: 25 }, (_, i) => `if (x${i}) {}`).join('\n');
const { rating } = analyseComplexity(code);
expect(rating).toBe('high');
});
});tests/complexity.test.ts
try/catch returning isError: true on failure — never throw to the protocol layer.env and any secrets added to .gitignore — never commit tokensconsole.error (stderr) — stdout is reserved for JSON-RPCdevdash://config resource and README.describe() on every field — Claude uses these descriptions to understand parametersreadOnlyHint, destructiveHint) set correctly on all toolsnpm testgithub.ts) — test by temporarily using an invalid tokennpm run build produces clean JS with no TypeScript errorscreate_pr_draft calls server.createMessage(). Which JSON-RPC method does this translate to on the wire?github://file/{owner}/{repo}/{+path} URI template uses {+path} instead of {path}. What does the + operator allow?analyze_complexity tool handler does NOT wrap its logic in try/catch. What happens if the analyser throws?src/index.ts, why must all debug logging use console.error instead of console.log?code-review prompt embeds a resource using type: 'resource' in its first message. What does this tell the MCP client to do?resources/read and inline the content into the conversation